chore: update eslint and prettier configuration

This commit is contained in:
2026-06-17 16:23:28 +05:30
parent b54242cf7e
commit 4c2241ff72
140 changed files with 2270 additions and 1287 deletions

View File

@@ -17,7 +17,9 @@ export default function LoginPage() {
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
<p className="text-sm font-medium text-muted-foreground">
VisionRoad
</p>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Use your email and password to continue.
@@ -39,7 +41,9 @@ export default function LoginPage() {
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
@@ -57,7 +61,9 @@ export default function LoginPage() {
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>

View File

@@ -11,8 +11,8 @@ export default function AccessPage() {
</div>
<h1 className="text-2xl font-semibold">Access denied</h1>
<p className="mt-2 text-sm text-muted-foreground">
You do not have permission to access any available module. Contact your administrator if
you need access.
You do not have permission to access any available module. Contact
your administrator if you need access.
</p>
</div>
</main>

View File

@@ -21,7 +21,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
{
accessorKey: 'name',
header: 'Client',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
),
},
{
accessorKey: 'email',
@@ -33,7 +35,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.contact_name}</p>
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p>
<p className="text-xs text-muted-foreground">
{row.original.contact_phone_number}
</p>
</div>
),
},

View File

@@ -38,8 +38,12 @@ export function ClientSheet({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle>
<DialogDescription>Manage company and primary contact details.</DialogDescription>
<DialogTitle>
{clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription>
Manage company and primary contact details.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
@@ -88,7 +92,11 @@ export function ClientSheet({
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="gst">GST</Label>
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
{...register('gst')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pan">PAN</Label>
@@ -141,7 +149,9 @@ export function ClientSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{clientId ? 'Update Client' : 'Create Client'}
</Button>
</DialogFooter>

View File

@@ -12,7 +12,8 @@ export function useClientFilters() {
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
const [statusFilter, setStatusFilterValue] =
useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {

View File

@@ -38,7 +38,9 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update client' : 'Failed to create client');
toast.error(
values.id ? 'Failed to update client' : 'Failed to create client',
);
},
});
}
@@ -46,7 +48,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
export function useClientStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
mutationFn: (client: Client) =>
clientService.updateClientStatus(client.id, !client.is_active),
onSuccess: () => {
toast.success('Client status updated');
queryClient.invalidateQueries({ queryKey: clientKeys.all });

View File

@@ -37,7 +37,8 @@ function buildClientListParams({
export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);

View File

@@ -44,7 +44,8 @@ export default function ClientsPage() {
const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false),
});
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
clientForm;
const { register, handleSubmit, clientId, isSaving } = clientForm;
const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation;

View File

@@ -30,7 +30,11 @@ import { DashboardMap } from '@/components/dashboard/dashboard-map';
import { PageHeader } from '@/components/page-header';
import { projectService, projectSummaryService } from '@/services/api';
import { Project } from '@/types';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
import { toast } from 'sonner';
@@ -160,7 +164,8 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
@@ -207,8 +212,12 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
export default function DashboardPage() {
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
null,
);
const [stats, setStats] = useState<DetectionStats>({
totalDefectedSignboard: 0,
totalPothole: 0,
@@ -230,7 +239,9 @@ export default function DashboardPage() {
setProjects(projectsData.items);
if (projectsData.items.length > 0) {
setSelectedProjectId(projectsData.items[projectsData.items.length - 1].id);
setSelectedProjectId(
projectsData.items[projectsData.items.length - 1].id,
);
}
} catch (err) {
console.error('Failed to load projects:', err);
@@ -258,12 +269,18 @@ export default function DashboardPage() {
: [];
// Extract chainages from selected package
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(null); // Changed 'selectedLocationId' to 'selectedChainageId'
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
null,
);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
null,
); // Changed 'selectedLocationId' to 'selectedChainageId'
const chainages =
projectSummary && selectedPackageId && selectedPackageId !== 'all'
? Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {}).map((chnName) => ({
? Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
).map((chnName) => ({
id: chnName,
name: chnName,
}))
@@ -281,9 +298,10 @@ export default function DashboardPage() {
try {
setIsLoading(true);
const summary = await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
const summary =
await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
setProjectSummary(summary);
} catch (err) {
console.error('Failed to load project summary:', err);
@@ -322,8 +340,15 @@ export default function DashboardPage() {
// Auto-select last chainage when package is selected
useEffect(() => {
if (projectSummary && selectedPackageId && selectedPackageId !== 'all' && selectedChainageId === null) {
const chainageIds = Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {});
if (
projectSummary &&
selectedPackageId &&
selectedPackageId !== 'all' &&
selectedChainageId === null
) {
const chainageIds = Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
);
if (chainageIds.length > 0) {
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
}
@@ -415,7 +440,8 @@ export default function DashboardPage() {
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
@@ -478,7 +504,10 @@ export default function DashboardPage() {
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
<PopoverContent
className="w-[320px] p-0 overflow-hidden"
align="end"
>
<div className="p-4 border-b bg-muted/30">
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
<Milestone className="h-4 w-4 text-primary" />
@@ -577,7 +606,9 @@ export default function DashboardPage() {
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle>Detection Distribution</CardTitle>
<CardDescription>Breakdown of all detected road conditions</CardDescription>
<CardDescription>
Breakdown of all detected road conditions
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<DetectionDonutChart
@@ -605,13 +636,18 @@ export default function DashboardPage() {
<Reveal delay={0.3} direction="right" className="flex flex-col">
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle className="text-base font-bold">Severity by Segment</CardTitle>
<CardTitle className="text-base font-bold">
Severity by Segment
</CardTitle>
<CardDescription className="text-xs">
Detections grouped by road segment
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ChainageBarChart data={stats.chainageData || []} isLoading={isLoading} />
<ChainageBarChart
data={stats.chainageData || []}
isLoading={isLoading}
/>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="leading-none text-muted-foreground">

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Package, Project } from '@/types';
function EmptyValue() {
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -70,14 +72,16 @@ export function usePackageColumns(projects: Project[]) {
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) =>
projects.find((project) => project.id === row.original.project_id)?.name ||
row.original.project_id,
projects.find((project) => project.id === row.original.project_id)
?.name || row.original.project_id,
},
{
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const project = projects.find((item) => item.id === row.original.project_id);
const project = projects.find(
(item) => item.id === row.original.project_id,
);
return <StateBadges value={project?.state ?? null} />;
},
},

View File

@@ -57,14 +57,23 @@ export function PackageDialog({
canSubmit,
isSaving,
}: PackageDialogProps) {
const projectErrorMessage = isSubmitted ? errors.project_id?.message : undefined;
const projectErrorMessage = isSubmitted
? errors.project_id?.message
: undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startErrorMessage = isSubmitted ? errors.chainage_start_km?.message : undefined;
const endErrorMessage = isSubmitted ? errors.chainage_end_km?.message : undefined;
const startErrorMessage = isSubmitted
? errors.chainage_start_km?.message
: undefined;
const endErrorMessage = isSubmitted
? errors.chainage_end_km?.message
: undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
@@ -101,12 +110,22 @@ export function PackageDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<div className="grid gap-5 md:grid-cols-2">
<FormField id="package-name" label="Package Name" required error={nameErrorMessage}>
<FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input
id="package-name"
placeholder="e.g. Package 01"
@@ -124,10 +143,18 @@ export function PackageDialog({
</span>
}
>
<Input id="package-region" placeholder="e.g. North Zone" {...register('region')} />
<Input
id="package-region"
placeholder="e.g. North Zone"
{...register('region')}
/>
</FormField>
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}>
<FormField
id="package-start"
label="Segment Start (km)"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
@@ -138,7 +165,11 @@ export function PackageDialog({
/>
</FormField>
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}>
<FormField
id="package-end"
label="Segment End (km)"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"

View File

@@ -49,7 +49,9 @@ function toNumberOrZero(value: string) {
return value.trim() ? Number(value) : 0;
}
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate {
function toPackagePayload(
values: PackageFormValues,
): PackageCreate | PackageUpdate {
return {
project_id: values.project_id,
name: values.name.trim(),
@@ -78,7 +80,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageName = useWatch({ control, name: 'name' }) || '';
const canSubmit = projectId.trim().length > 0 && packageName.trim().length > 0;
const canSubmit =
projectId.trim().length > 0 && packageName.trim().length > 0;
const saveMutation = useMutation({
mutationFn: (values: PackageFormValues) => {
@@ -94,7 +97,9 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: packageKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update package' : 'Failed to create package');
toast.error(
values.id ? 'Failed to update package' : 'Failed to create package',
);
},
});
@@ -130,7 +135,11 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
);
const setProjectId = useCallback(
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }),
(value: string) =>
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -15,7 +15,10 @@ interface UsePackagesQueryParams {
}
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: packageKeys.list(listParams),

View File

@@ -37,7 +37,8 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle),
cell: ({ row }) =>
formatPrice(row.original.price, row.original.billing_cycle),
},
{
accessorKey: 'trial_days',
@@ -68,7 +69,9 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
{row.original.is_custom ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div>
),
},

View File

@@ -31,7 +31,10 @@ export function PlanFilters({
placeholder="Search plans"
className="w-full sm:w-72"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>

View File

@@ -201,7 +201,9 @@ export function PlanSheet({
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-muted-foreground">{permissionIds.length} selected</span>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
</div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground">
@@ -226,7 +228,9 @@ export function PlanSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{planId ? 'Update Plan' : 'Create Plan'}
</Button>
</SheetFooter>

View File

@@ -117,7 +117,10 @@ export function usePlanForm({
max_organizations: plan.max_organizations ?? 0,
max_users: plan.max_users ?? 0,
max_roles: plan.max_roles ?? 0,
permission_ids: collectPermissionIdsByKeys(permissionTree, plan.permissions || []),
permission_ids: collectPermissionIdsByKeys(
permissionTree,
plan.permissions || [],
),
is_active: plan.is_active,
is_custom: plan.is_custom,
});
@@ -126,7 +129,11 @@ export function usePlanForm({
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -19,7 +19,9 @@ export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update plan' : 'Failed to create plan');
toast.error(
values.id ? 'Failed to update plan' : 'Failed to create plan',
);
},
});
}
@@ -28,7 +30,8 @@ export function usePlanStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active),
mutationFn: (plan: Plan) =>
planService.updatePlanStatus(plan.id, !plan.is_active),
onSuccess: () => {
toast.success('Plan status updated');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });

View File

@@ -16,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations';
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries';
import {
useOrganizationPermissionTreeQuery,
usePlansQuery,
} from './hooks/usePlanQueries';
export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -57,8 +60,15 @@ export default function PlansPage() {
onSaved: () => handleSheetOpenChange(false),
});
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
const { register, control, onSubmit, planId, permissionIds, setPermissionIds, isSaving } =
planForm;
const {
register,
control,
onSubmit,
planId,
permissionIds,
setPermissionIds,
isSaving,
} = planForm;
const statusMutation = usePlanStatusMutation();
const openCreate = useCallback(() => {

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Project } from '@/types';
function EmptyValue() {
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}

View File

@@ -42,14 +42,21 @@ export function ProjectDialog({
isSaving,
}: ProjectDialogProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startLatErrorMessage = isSubmitted ? errors.start_lat?.message : undefined;
const startLngErrorMessage = isSubmitted ? errors.start_lng?.message : undefined;
const startLatErrorMessage = isSubmitted
? errors.start_lat?.message
: undefined;
const startLngErrorMessage = isSubmitted
? errors.start_lng?.message
: undefined;
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
@@ -65,7 +72,12 @@ export function ProjectDialog({
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
<FormField id="project-name" label="Project Name" required error={nameErrorMessage}>
<FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input
id="project-name"
placeholder="Enter a descriptive project name"
@@ -76,7 +88,11 @@ export function ProjectDialog({
<div className="grid gap-5 md:grid-cols-2">
<FormField id="project-state" label="State">
<Input id="project-state" placeholder="e.g. Maharashtra" {...register('state')} />
<Input
id="project-state"
placeholder="e.g. Maharashtra"
{...register('state')}
/>
</FormField>
<FormField
id="project-corridor"
@@ -102,7 +118,11 @@ export function ProjectDialog({
START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-start-lat" label="Lat" error={startLatErrorMessage}>
<FormField
id="project-start-lat"
label="Lat"
error={startLatErrorMessage}
>
<Input
id="project-start-lat"
type="number"
@@ -113,7 +133,11 @@ export function ProjectDialog({
{...register('start_lat')}
/>
</FormField>
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}>
<FormField
id="project-start-lng"
label="Lng"
error={startLngErrorMessage}
>
<Input
id="project-start-lng"
type="number"
@@ -133,7 +157,11 @@ export function ProjectDialog({
END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-end-lat" label="Lat" error={endLatErrorMessage}>
<FormField
id="project-end-lat"
label="Lat"
error={endLatErrorMessage}
>
<Input
id="project-end-lat"
type="number"
@@ -144,7 +172,11 @@ export function ProjectDialog({
{...register('end_lat')}
/>
</FormField>
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}>
<FormField
id="project-end-lng"
label="Lng"
error={endLngErrorMessage}
>
<Input
id="project-end-lng"
type="number"

View File

@@ -53,7 +53,9 @@ function toNullableNumber(value: string) {
return value.trim() ? Number(value) : null;
}
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate {
function toProjectPayload(
values: ProjectFormValues,
): ProjectCreate | ProjectUpdate {
return {
name: values.name.trim(),
state: trimOptional(values.state),
@@ -98,7 +100,9 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update project' : 'Failed to create project');
toast.error(
values.id ? 'Failed to update project' : 'Failed to create project',
);
},
});

View File

@@ -15,7 +15,10 @@ interface UseProjectsQueryParams {
}
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: projectKeys.list(listParams),
@@ -36,7 +39,8 @@ export function useDeleteProjectMutation() {
},
onError: () => {
toast.error('Deletion failed', {
description: 'The project could not be removed. Please try again or check your permissions.',
description:
'The project could not be removed. Please try again or check your permissions.',
});
},
});

View File

@@ -12,7 +12,10 @@ import { ProjectTable } from './components/ProjectTable';
import { useProjectColumns } from './components/ProjectColumns';
import { useProjectFilters } from './hooks/useProjectFilters';
import { useProjectForm } from './hooks/useProjectForm';
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries';
import {
useDeleteProjectMutation,
useProjectsQuery,
} from './hooks/useProjectQueries';
export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -60,7 +63,9 @@ export default function ProjectPage() {
const deleteProject = useCallback(
(project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) {
if (
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return;
}
deleteProjectMutation.mutate(project);

View File

@@ -19,8 +19,11 @@ export default function VideoResultsPage() {
const router = useRouter();
const { videoId } = useParams() as { videoId: string };
const [session, setSession] = useState<SessionContext | null>(null);
const [detectionData, setDetectionData] = useState<DetectionData | null>(null);
const [detectionType, setDetectionType] = useState<DetectionType>('pothole-detection');
const [detectionData, setDetectionData] = useState<DetectionData | null>(
null,
);
const [detectionType, setDetectionType] =
useState<DetectionType>('pothole-detection');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -89,7 +92,9 @@ export default function VideoResultsPage() {
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading detection results...</p>
<p className="text-sm text-muted-foreground">
Loading detection results...
</p>
</Card>
</div>
);
@@ -101,7 +106,10 @@ export default function VideoResultsPage() {
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p>
<div className="flex gap-4">
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">
<Button
onClick={() => router.push(ROUTES.UPLOAD)}
variant="outline"
>
Back to Upload
</Button>
<Button onClick={handleNewAnalysis}>New Analysis</Button>
@@ -116,7 +124,11 @@ export default function VideoResultsPage() {
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8">
<PageHeader title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} />
<PageHeader
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (

View File

@@ -55,8 +55,10 @@ export default function ResultsPage() {
};
const getTitle = () => {
if (detectionType === 'pothole-detection') return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection') return 'Signboard Detection Results';
if (detectionType === 'pothole-detection')
return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection')
return 'Signboard Detection Results';
return 'Pothole & Signboard Detection Results';
};

View File

@@ -36,7 +36,9 @@ export function useRoleColumns(): ColumnDef<Role>[] {
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
<Badge
variant={row.original.effective_status ? 'default' : 'secondary'}
>
{row.original.effective_status ? 'Active' : 'Inactive'}
</Badge>
),

View File

@@ -31,7 +31,10 @@ export function RoleFilters({
onChange={onSearchChange}
placeholder="Search roles"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>

View File

@@ -54,7 +54,9 @@ export function RoleSheet({
isSaving,
}: RoleSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const displayNameErrorMessage = isSubmitted ? errors.display_name?.message : undefined;
const displayNameErrorMessage = isSubmitted
? errors.display_name?.message
: undefined;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -72,7 +74,12 @@ export function RoleSheet({
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField id="role-name" label="Role Name" required error={nameErrorMessage}>
<FormField
id="role-name"
label="Role Name"
required
error={nameErrorMessage}
>
<Input
id="role-name"
placeholder="Enter role name"
@@ -110,7 +117,9 @@ export function RoleSheet({
error={isSubmitted ? errors.permission_ids?.message : undefined}
className="space-y-3"
labelEnd={
<span className="text-muted-foreground">{permissionIds.length} selected</span>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
}
>
{isPermissionsLoading ? (
@@ -129,7 +138,9 @@ export function RoleSheet({
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{roleId ? 'Update Role' : 'Create Role'}
</Button>
<SheetClose asChild>

View File

@@ -61,7 +61,9 @@ export function useRoleForm({
const roleName = useWatch({ control, name: 'name' }) || '';
const displayName = useWatch({ control, name: 'display_name' }) || '';
const canSubmit =
roleName.trim().length > 0 && displayName.trim().length > 0 && permissionIds.length > 0;
roleName.trim().length > 0 &&
displayName.trim().length > 0 &&
permissionIds.length > 0;
const saveMutation = useMutation({
mutationFn: (values: RoleFormValues) =>
@@ -81,7 +83,9 @@ export function useRoleForm({
queryClient.invalidateQueries({ queryKey: roleKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update role' : 'Failed to create role');
toast.error(
values.id ? 'Failed to update role' : 'Failed to create role',
);
},
});
@@ -116,14 +120,21 @@ export function useRoleForm({
name: role.name || '',
display_name: role.display_name || '',
description: role.description || '',
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
permission_ids: collectPermissionIdsByKeys(
permissionTree,
role.permissions || [],
),
});
},
[permissionTree, reset],
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -32,7 +32,8 @@ function buildRoleListParams({
skip,
limit,
search_term: searchTerm || undefined,
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
effective_status:
statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting),
};
}
@@ -40,7 +41,8 @@ function buildRoleListParams({
export function useRolesQuery(params: UseRolesQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);
@@ -71,7 +73,8 @@ export function useRoleStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
mutationFn: (role: Role) =>
roleService.updateRoleStatus(role.id, !role.effective_status),
onSuccess: () => {
toast.success('Role status updated');
queryClient.invalidateQueries({ queryKey: roleKeys.all });

View File

@@ -23,7 +23,9 @@ import {
} from './hooks/useRoleQueries';
export default function RolesPage() {
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
const organizationId = useAppStore(
(state) => state.user?.organization_id ?? null,
);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
skip,
@@ -66,7 +68,8 @@ export default function RolesPage() {
isSaving,
} = roleForm;
const statusMutation = useRoleStatusMutation();
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
const { mutate: updateRoleStatus, isPending: isStatusPending } =
statusMutation;
const openCreate = useCallback(() => {
prepareCreateRole();

View File

@@ -2,10 +2,19 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react';
import {
ArrowDownCircle,
ArrowUpCircle,
MapPin,
Milestone,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Chainage, Package, Project } from '@/types';
function EmptyValue() {
@@ -43,9 +52,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project',
header: 'Project',
cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id);
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return project?.name || <EmptyValue />;
},
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id);
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return <StateBadges value={project?.state ?? null} />;
},
@@ -127,7 +138,8 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
>
<MapPin className="size-3" />
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
{row.original.start_lat.toFixed(4)},{' '}
{row.original.start_lng.toFixed(4)}
</Badge>
),
},

View File

@@ -95,7 +95,11 @@ export function SegmentDialog({
<form onSubmit={onSubmit} className="space-y-6">
{!segmentId ? (
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Project" required error={getError('project_id')}>
<FormField
label="Project"
required
error={getError('project_id')}
>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!getError('project_id')}>
{isProjectsLoading ? (
@@ -115,10 +119,19 @@ export function SegmentDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
<FormField label="Package" required error={getError('package_id')}>
<FormField
label="Package"
required
error={getError('package_id')}
>
<Select
value={packageId}
onValueChange={onPackageChange}
@@ -132,7 +145,11 @@ export function SegmentDialog({
</span>
) : (
<SelectValue
placeholder={projectId ? 'Choose a package' : 'Select project first'}
placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/>
)}
</SelectTrigger>
@@ -144,7 +161,12 @@ export function SegmentDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('package_id')} value={packageId} readOnly />
<input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField>
</div>
) : null}
@@ -218,7 +240,12 @@ export function SegmentDialog({
START COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="start-lat" label="Latitude" required error={getError('start_lat')}>
<FormField
id="start-lat"
label="Latitude"
required
error={getError('start_lat')}
>
<Input
id="start-lat"
type="number"
@@ -230,7 +257,12 @@ export function SegmentDialog({
{...register('start_lat')}
/>
</FormField>
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}>
<FormField
id="start-lng"
label="Longitude"
required
error={getError('start_lng')}
>
<Input
id="start-lng"
type="number"
@@ -251,7 +283,12 @@ export function SegmentDialog({
END COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="end-lat" label="Latitude" required error={getError('end_lat')}>
<FormField
id="end-lat"
label="Latitude"
required
error={getError('end_lat')}
>
<Input
id="end-lat"
type="number"
@@ -263,7 +300,12 @@ export function SegmentDialog({
{...register('end_lat')}
/>
</FormField>
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}>
<FormField
id="end-lng"
label="Longitude"
required
error={getError('end_lng')}
>
<Input
id="end-lng"
type="number"

View File

@@ -17,7 +17,9 @@ const requiredNumber = (message: string) =>
.string()
.trim()
.min(1, message)
.refine((value) => Number.isFinite(Number(value)), { message: 'Enter a valid number' });
.refine((value) => Number.isFinite(Number(value)), {
message: 'Enter a valid number',
});
const latitude = requiredNumber('Latitude is required').refine(
(value) => {
@@ -64,7 +66,9 @@ const defaultValues: SegmentFormValues = {
direction: 'UP',
};
function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate {
function toSegmentPayload(
values: SegmentFormValues,
): ChainageCreate | ChainageUpdate {
return {
package_id: values.package_id,
segment_name: values.segment_name.trim(),
@@ -99,7 +103,8 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'package_id' }) || '';
const direction = useWatch({ control, name: 'direction' }) || 'UP';
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
const chainageStartKm = useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageStartKm =
useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
const startLat = useWatch({ control, name: 'start_lat' }) || '';
const startLng = useWatch({ control, name: 'start_lng' }) || '';
@@ -130,14 +135,18 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update segment' : 'Failed to create segment');
toast.error(
values.id ? 'Failed to update segment' : 'Failed to create segment',
);
},
});
const onSubmit = handleSubmit(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find((error) => error?.message)?.message;
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
@@ -169,14 +178,21 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const setProjectId = useCallback(
(value: string) => {
setValue('project_id', value, { shouldDirty: true, shouldValidate: true });
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
});
setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
},
[setValue],
);
const setPackageId = useCallback(
(value: string) => setValue('package_id', value, { shouldDirty: true, shouldValidate: true }),
(value: string) =>
setValue('package_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -4,7 +4,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { chainageService, packageService, projectService } from '@/services/api';
import {
chainageService,
packageService,
projectService,
} from '@/services/api';
import type { Chainage, PaginationParams } from '@/types';
import { segmentKeys } from '../queries/segmentKeys';
@@ -15,7 +19,10 @@ interface UseSegmentsQueryParams {
}
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: segmentKeys.list(listParams),
@@ -40,7 +47,8 @@ export function useAllPackageOptionsQuery() {
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
return useQuery({
queryKey: ['packages', 'by-project', projectId],
queryFn: () => packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
queryFn: () =>
packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
enabled: enabled && Boolean(projectId),
});
}
@@ -49,7 +57,8 @@ export function useDeleteSegmentMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id),
mutationFn: (segment: Chainage) =>
chainageService.deleteChainage(segment.id),
onSuccess: (_data, segment) => {
toast.success('Segment deleted', {
description: `${segment.segment_name} has been removed from the system.`,

View File

@@ -48,7 +48,10 @@ export default function SegmentPage() {
canSubmit,
isSaving,
} = segmentForm;
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
const projectPackagesQuery = usePackagesByProjectQuery(
projectId,
isDialogOpen,
);
const openCreate = useCallback(() => {
prepareCreateSegment();
@@ -57,7 +60,9 @@ export default function SegmentPage() {
const openEdit = useCallback(
(segment: Chainage) => {
const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id);
const pkg = allPackagesQuery.data?.items.find(
(item) => item.id === segment.package_id,
);
prepareEditSegment(segment, pkg?.project_id || '');
setIsDialogOpen(true);
},
@@ -76,7 +81,11 @@ export default function SegmentPage() {
const deleteSegment = useCallback(
(segment: Chainage) => {
if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) {
if (
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return;
}
deleteSegmentMutation.mutate(segment);

View File

@@ -67,15 +67,20 @@ export function TenantSheet({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
<DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription>
Bind a client and subscription plan, then invite the tenant administrator.
Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p>Basic Information</p>
<p className="text-muted-foreground">Tenant identity and subscription binding.</p>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -106,10 +111,16 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
<Select
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'}
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
@@ -123,10 +134,16 @@ export function TenantSheet({
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
<Select
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'}
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
@@ -143,7 +160,11 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
@@ -226,7 +247,9 @@ export function TenantSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{tenantId ? 'Update Tenant' : 'Create Tenant'}
</Button>
</DialogFooter>

View File

@@ -12,7 +12,8 @@ export function useTenantFilters() {
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
const [statusFilter, setStatusFilterValue] =
useState<TenantStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {

View File

@@ -82,7 +82,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
}
if (!isEditMode) {
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
if (
!values.admin_first_name.trim() ||
!values.admin_last_name.trim() ||
!values.admin_email.trim()
) {
toast.error('Complete all required admin fields');
return;
}

View File

@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
toast.error(
values.id ? 'Failed to update tenant' : 'Failed to create tenant',
);
},
});
}

View File

@@ -122,7 +122,8 @@ export default function TenantsPage() {
const total = tenantsQuery.data?.total ?? 0;
const clients = clientsLookupQuery.data?.items ?? [];
const plans = plansLookupQuery.data?.items ?? [];
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const isLookupsLoading =
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const toolbar = useMemo(
() => (
@@ -167,7 +168,9 @@ export default function TenantsPage() {
onSortingChange={setSorting}
onEdit={openEdit}
onDelete={handleDelete}
pendingDeleteId={deleteMutation.isPending ? deleteMutation.variables : undefined}
pendingDeleteId={
deleteMutation.isPending ? deleteMutation.variables : undefined
}
/>
</main>

View File

@@ -11,7 +11,10 @@ import { ROUTES } from '@/utils/routes';
import { Button } from '@/components/ui/button';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(
/^http:\/\//,
'ws://',
);
export default function VideoProcessingPage() {
const router = useRouter();
@@ -40,12 +43,12 @@ export default function VideoProcessingPage() {
setProgress(data.progress || 0);
let message = data.message || 'Processing...';
const uniqueCount =
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_drain_issue;
if (uniqueCount !== undefined) {
message += ` | Unique: ${uniqueCount} | Total: ${data.total_detections || 0}`;
@@ -101,7 +104,9 @@ export default function VideoProcessingPage() {
}
if (statusData.status === 'error') {
setError(statusData.message || 'An error occurred during processing.');
setError(
statusData.message || 'An error occurred during processing.',
);
setIsLoading(false);
return;
}
@@ -203,7 +208,9 @@ export default function VideoProcessingPage() {
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight">Uploading ...</h2>
<h2 className="text-3xl font-bold tracking-tight">
Uploading ...
</h2>
<p className="text-sm font-mono text-muted-foreground tracking-widest">
ID: {videoId}
</p>
@@ -214,7 +221,9 @@ export default function VideoProcessingPage() {
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
Progress
</span>
<span className="font-bold text-primary text-lg">{progress}%</span>
<span className="font-bold text-primary text-lg">
{progress}%
</span>
</div>
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
<div
@@ -233,7 +242,11 @@ export default function VideoProcessingPage() {
{error && (
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
<p className="text-destructive font-medium">{error}</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Retry Connection
</Button>
</div>

View File

@@ -2,7 +2,13 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -49,17 +55,20 @@ export default function UploadPage() {
// Load session on mount
useEffect(() => {
// We don't load session on mount for the upload page to ensure
// We don't load session on mount for the upload page to ensure
// project details are filled manually as requested.
setIsLoading(false);
}, []);
const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => {
setSession(selectedSession);
if (selectedSession) {
sessionService.saveSession(selectedSession);
}
}, []);
const handleSelectionChange = useCallback(
(selectedSession: SessionContext | null) => {
setSession(selectedSession);
if (selectedSession) {
sessionService.saveSession(selectedSession);
}
},
[],
);
const handleUpload = async () => {
if (!file) {
@@ -103,7 +112,8 @@ export default function UploadPage() {
} catch (err) {
let errorMessage = 'Upload failed';
if (err instanceof TypeError && err.message === 'Failed to fetch') {
errorMessage = 'Cannot connect to server. Please check if backend is running.';
errorMessage =
'Cannot connect to server. Please check if backend is running.';
} else if (err instanceof Error) {
errorMessage = err.message;
}
@@ -120,7 +130,9 @@ export default function UploadPage() {
);
}
const isSessionComplete = !!(session && sessionService.isSessionValid(session));
const isSessionComplete = !!(
session && sessionService.isSessionValid(session)
);
const isFormValid = isSessionComplete && file && jsonFile;
return (
@@ -140,14 +152,16 @@ export default function UploadPage() {
<Reveal direction="up" delay={0.1}>
<Card className="border">
<CardHeader>
<CardTitle className="text-xl font-bold">1. Project Details</CardTitle>
<CardTitle className="text-xl font-bold">
1. Project Details
</CardTitle>
<CardDescription>
Select the target project, package, and segment.
</CardDescription>
</CardHeader>
<CardContent>
<ProjectSelectionSection
onSelectionChange={handleSelectionChange}
<ProjectSelectionSection
onSelectionChange={handleSelectionChange}
asStep={true}
hideButton={true}
/>
@@ -157,7 +171,13 @@ export default function UploadPage() {
{/* Module 2: Upload Data */}
<Reveal direction="up" delay={0.2}>
<Card className={cn("border transition-all duration-300", !isSessionComplete && "opacity-60 pointer-events-none grayscale-[0.5]")}>
<Card
className={cn(
'border transition-all duration-300',
!isSessionComplete &&
'opacity-60 pointer-events-none grayscale-[0.5]',
)}
>
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center gap-2">
2. Upload Video Data
@@ -170,7 +190,10 @@ export default function UploadPage() {
<div className="grid grid-cols-1 gap-6 text-sm">
{/* Video File Input */}
<div className="space-y-2">
<Label htmlFor="video-file" className="text-sm font-semibold">
<Label
htmlFor="video-file"
className="text-sm font-semibold"
>
Video File <span className="text-destructive">*</span>
</Label>
<div className="relative group">
@@ -191,8 +214,12 @@ export default function UploadPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* JSON File Input */}
<div className="space-y-2">
<Label htmlFor="json-file" className="text-sm font-semibold">
GPS JSON File <span className="text-destructive">*</span>
<Label
htmlFor="json-file"
className="text-sm font-semibold"
>
GPS JSON File{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="json-file"
@@ -210,7 +237,10 @@ export default function UploadPage() {
{/* Select Method */}
<div className="space-y-2">
<Label htmlFor="select-method" className="text-sm font-semibold">
<Label
htmlFor="select-method"
className="text-sm font-semibold"
>
Analysis Method
</Label>
<Select
@@ -218,7 +248,10 @@ export default function UploadPage() {
onValueChange={setSelectMethod}
disabled={uploading || !isSessionComplete}
>
<SelectTrigger id="select-method" className="h-11 bg-muted/20">
<SelectTrigger
id="select-method"
className="h-11 bg-muted/20"
>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>

View File

@@ -23,8 +23,9 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
header: 'Name',
cell: ({ row }) => (
<span>
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
row.original.username}
{[row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(' ') || row.original.username}
</span>
),
},
@@ -64,7 +65,13 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}>
<Badge
variant={
row.original.effective_status === 'active'
? 'default'
: 'secondary'
}
>
{row.original.effective_status}
</Badge>
),

View File

@@ -30,7 +30,10 @@ export function UserFilters({
onChange={onSearchChange}
placeholder="Search users"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>

View File

@@ -46,10 +46,16 @@ export function UserSheet({
isSaving,
}: UserSheetProps) {
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted ? errors.first_name?.message : undefined;
const lastNameErrorMessage = isSubmitted ? errors.last_name?.message : undefined;
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const phoneErrorMessage = isSubmitted ? errors.phone_number?.message : undefined;
const phoneErrorMessage = isSubmitted
? errors.phone_number?.message
: undefined;
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
return (
@@ -63,7 +69,12 @@ export function UserSheet({
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField id="first-name" label="First Name" required error={firstNameErrorMessage}>
<FormField
id="first-name"
label="First Name"
required
error={firstNameErrorMessage}
>
<Input
id="first-name"
placeholder="Enter first name"
@@ -72,7 +83,12 @@ export function UserSheet({
/>
</FormField>
<FormField id="last-name" label="Last Name" required error={lastNameErrorMessage}>
<FormField
id="last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="last-name"
placeholder="Enter last name"
@@ -82,7 +98,12 @@ export function UserSheet({
</FormField>
</div>
<FormField id="email" label="Email" required error={emailErrorMessage}>
<FormField
id="email"
label="Email"
required
error={emailErrorMessage}
>
<Input
id="email"
placeholder="name@example.com"
@@ -91,7 +112,11 @@ export function UserSheet({
/>
</FormField>
<FormField id="phone-number" label="Phone Number" error={phoneErrorMessage}>
<FormField
id="phone-number"
label="Phone Number"
error={phoneErrorMessage}
>
<Input
id="phone-number"
placeholder="+919876543210"
@@ -111,7 +136,12 @@ export function UserSheet({
<div ref={roleComboboxPortalRef} />
<input type="hidden" {...register('role_id')} value={roleId} readOnly />
<input
type="hidden"
{...register('role_id')}
value={roleId}
readOnly
/>
</FormField>
<DialogFooter className="px-0">
@@ -125,7 +155,9 @@ export function UserSheet({
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{userId ? 'Update User' : 'Create User'}
</Button>

View File

@@ -7,7 +7,12 @@ interface UserStatsProps {
pendingCount: number;
}
export function UserStats({ total, activeCount, inactiveCount, pendingCount }: UserStatsProps) {
export function UserStats({
total,
activeCount,
inactiveCount,
pendingCount,
}: UserStatsProps) {
const stats = [
{ label: 'Total', value: total },
{ label: 'Active', value: activeCount },

View File

@@ -57,10 +57,12 @@ export function UserTable({
onClick: onEdit,
},
{
label: (user) => (user.effective_status === 'active' ? 'Deactivate' : 'Activate'),
label: (user) =>
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.USER.DELETE,
disabled: (user) => pendingUserId === user.id || user.effective_status === 'pending',
disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending',
onClick: onToggleStatus,
},
]}

View File

@@ -63,7 +63,8 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
const email = useWatch({ control, name: 'email' }) || '';
const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
const isPhoneValid =
phoneNumber.trim() === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
phoneNumber.trim() === '' ||
/^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
const canSubmit =
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&

View File

@@ -31,7 +31,9 @@ export function useSaveUserMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: userKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update user' : 'Failed to create user');
toast.error(
values.id ? 'Failed to update user' : 'Failed to create user',
);
},
});
}
@@ -40,7 +42,10 @@ export function useUserStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (user: AdministrationUser) =>
userService.updateUserStatus(user.id, getNextStatus(user.effective_status)),
userService.updateUserStatus(
user.id,
getNextStatus(user.effective_status),
),
onSuccess: () => {
toast.success('User status updated');
queryClient.invalidateQueries({ queryKey: userKeys.all });

View File

@@ -37,7 +37,8 @@ function buildUserListParams({
export function useUsersQuery(params: UseUsersQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);

View File

@@ -17,8 +17,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
title: {
default: 'VisionRoad',
template: '%s | VisionRoad',
},
description: 'Road infrastructure monitoring and pothole detection platform',
};
export default function RootLayout({
@@ -28,7 +31,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"