Skip to content
64 changes: 22 additions & 42 deletions src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

import React from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useShippingBatchDetails } from '@/core/hooks/useDevices';
import { useShippingBatchDetails, useGenerateShippingLabels } from '@/core/hooks/useDevices';
import { format } from 'date-fns';
import { ArrowLeft } from 'lucide-react';
import ReusableTable, { TableColumn } from '@/components/shared/table/ReusableTable';
import { ShippingStatusDevice } from '@/app/types/devices';
import { Skeleton } from "@/components/ui/skeleton";
import ReusableButton from '@/components/shared/button/ReusableButton';
import { AqArrowLeft } from '@airqo/icons-react';
import { useGenerateShippingLabels } from '@/core/hooks/useDevices';
import ReusableToast from '@/components/shared/toast/ReusableToast';
import { useBanner } from '@/context/banner-context';
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage';
import ShippingLabelPrintModal from '@/components/features/shipping/ShippingLabelPrintModal';
import { useQueryClient } from '@tanstack/react-query';
import { useState, useCallback } from 'react';
Expand All @@ -29,7 +29,18 @@ const BatchDetailsPage = () => {
const queryClient = useQueryClient();
const batchId = params.batchId as string;
const { data, isLoading, error } = useShippingBatchDetails(batchId);
const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels();
const { showBanner } = useBanner();
const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels({
onSuccess: (data) => {
if (data.success) {
showBanner({ severity: 'success', message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, scoped: false });
setShowLabelModal(true);
}
},
onError: (error) => {
showBanner({ severity: 'error', message: `Label Generation Failed: ${getApiErrorMessage(error)}`, scoped: false });
},
});
const [showLabelModal, setShowLabelModal] = useState(false);
usePageTitle({
title: data?.batch?.batch_name || "Shipping Batch",
Expand All @@ -38,10 +49,7 @@ const BatchDetailsPage = () => {

const handleGenerateLabels = useCallback((ids: (string | number)[]) => {
if (!ids || ids.length === 0) {
ReusableToast({
message: 'Please select at least one device',
type: 'ERROR',
});
showBanner({ severity: 'error', message: 'Please select at least one device', scoped: false });
return;
}

Expand All @@ -51,26 +59,12 @@ const BatchDetailsPage = () => {
.filter(name => name && name.trim().length > 0);

if (selectedDeviceNames.length === 0) {
ReusableToast({
message: 'Selected devices have no valid names',
type: 'ERROR',
});
showBanner({ severity: 'error', message: 'Selected devices have no valid names', scoped: false });
return;
}

generateLabels(selectedDeviceNames, {
onSuccess: (data) => {
if (data.success) {
ReusableToast({
message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`,
type: 'SUCCESS',
});

setShowLabelModal(true);
}
}
});
}, [generateLabels, data?.batch?.devices]);
generateLabels(selectedDeviceNames);
}, [generateLabels, data?.batch?.devices, showBanner]);

const handleGenerateAllLabels = useCallback(() => {
const allDeviceNames = (data?.batch?.devices || [])
Expand All @@ -79,26 +73,12 @@ const BatchDetailsPage = () => {
.filter(name => name && name.trim().length > 0);

if (allDeviceNames.length === 0) {
ReusableToast({
message: 'No unclaimed devices found with valid names in this batch',
type: 'ERROR',
});
showBanner({ severity: 'error', message: 'No unclaimed devices found with valid names in this batch', scoped: false });
return;
}

generateLabels(allDeviceNames, {
onSuccess: (data) => {
if (data.success) {
ReusableToast({
message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`,
type: 'SUCCESS',
});

setShowLabelModal(true);
}
}
});
}, [generateLabels, data?.batch?.devices]);
generateLabels(allDeviceNames);
}, [generateLabels, data?.batch?.devices, showBanner]);

const handleCloseModal = useCallback(() => {
setShowLabelModal(false);
Expand Down
38 changes: 38 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@

---

## Version 1.23.59
**Released:** June 04, 2026

### Decouple useDevices Mutations from ReusableToast

Removed all hardcoded `ReusableToast` calls from mutation hooks in `useDevices.ts` and delegated notification responsibility to the calling UI layer via optional `onSuccess`/`onError` callback interfaces. Also migrated remaining device-related components still using `ReusableToast` directly, and extracted a shared `useClipboard` hook to eliminate the repeated clipboard copy pattern across the codebase.

<details>
<summary><strong>Changes (5)</strong></summary>

- **Hooks Decoupled**: Removed `ReusableToast` from 8 mutation hooks — `useClaimDevice`, `useBulkClaimDevices`, `useUnassignDeviceFromOrganization`, `useUpdateDeviceBulk`, `useUpdateDeviceGroup`, `usePrepareDeviceForShipping`, `usePrepareBulkDevicesForShipping`, `useGenerateShippingLabels` — and added optional `onSuccess`/`onError` callback interfaces so the UI layer controls notification scope.
- **Bulk Edit Modal**: Wired `useUpdateDeviceBulk` hook-level callbacks; error uses `scoped: true` inline in the dialog, success uses `showBannerWithDelay` (`scoped: false`) after the dialog closes.
- **Prepare Shipping Modal**: Replaced all 8 `ReusableToast` calls with `useBanner`; file/import validation errors use `scoped: true`, bulk preparation success uses `showBannerWithDelay` (`scoped: false`).
- **Remaining Device Components**: Migrated `device-measurements-api-card.tsx`, `device-activity-item.tsx`, and `orphaned-devices-alert.tsx` from `ReusableToast` to `useBanner`. Migrated `shipping/[batchId]/page.tsx` with hook-level callbacks and `showBanner` (`scoped: false`).
- **Shared `useClipboard` Hook**: Extracted a `useClipboard` hook (accepts optional `successMessage`, `errorMessage`, `scoped`) to replace the repeated `navigator.clipboard.writeText` + banner pattern duplicated across `cohort-measurements-api-card`, `grid-measurements-api-card`, `grid-details-card`, `admin-levels-modal`, and `device-activity-item`.

</details>

<details>
<summary><strong>Files Updated (12)</strong></summary>

- `src/vertex/core/hooks/useDevices.ts` [MODIFIED]
- `src/vertex/core/hooks/useClipboard.ts` [NEW]
- `src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx` [MODIFIED]
- `src/vertex/components/features/devices/device-measurements-api-card.tsx` [MODIFIED]
- `src/vertex/components/features/devices/device-activity-item.tsx` [MODIFIED]
- `src/vertex/components/features/devices/orphaned-devices-alert.tsx` [MODIFIED]
- `src/vertex/components/features/shipping/PrepareShippingModal.tsx` [MODIFIED]
- `src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx` [MODIFIED]
- `src/vertex/components/features/grids/grid-measurements-api-card.tsx` [MODIFIED]
- `src/vertex/components/features/grids/grid-details-card.tsx` [MODIFIED]
- `src/vertex/components/features/grids/admin-levels-modal.tsx` [MODIFIED]

</details>

---

## Version 1.23.58
**Released:** June 03, 2026

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,14 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Copy } from "lucide-react";
import React from "react";
import { useBanner } from "@/context/banner-context";
import { useClipboard } from "@/core/hooks/useClipboard";

interface CohortMeasurementsApiCardProps {
cohortId: string;
}

const CohortMeasurementsApiCard: React.FC<CohortMeasurementsApiCardProps> = ({ cohortId }) => {
const { showBanner } = useBanner();

const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showBanner({ severity: 'success', message: 'API URL copied to clipboard', scoped: false });
} catch {
showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false });
}
};
const { handleCopy } = useClipboard({ successMessage: 'API URL copied to clipboard', errorMessage: 'Failed to copy to clipboard' });

return (
<Card className="w-full rounded-lg flex flex-col gap-4 px-3 py-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"
import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
import { DEVICE_CATEGORIES } from "@/core/constants/devices";
import { useUpdateDeviceBulk } from "@/core/hooks/useDevices";
import { useBanner } from "@/context/banner-context";
import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";

type EditableDeviceField =
| "category"
Expand All @@ -25,7 +28,16 @@ export default function BulkEditDevicesModal({
onClose,
deviceIds,
}: BulkEditDevicesModalProps) {
const bulkUpdate = useUpdateDeviceBulk();
const { showBanner } = useBanner();
const { showBannerWithDelay } = useBannerWithDelay();
const bulkUpdate = useUpdateDeviceBulk({
onSuccess: () => {
showBannerWithDelay({ severity: 'success', message: 'Devices updated successfully.', scoped: false });
},
onError: (error) => {
showBanner({ severity: 'error', message: `Bulk update failed: ${getApiErrorMessage(error)}`, scoped: true });
},
});

const [step, setStep] = useState<"choose_field" | "confirm">("choose_field");
const [selectedField, setSelectedField] = useState<EditableDeviceField | null>(null);
Expand Down Expand Up @@ -76,12 +88,7 @@ export default function BulkEditDevicesModal({

const updateData = { [selectedField]: value };

bulkUpdate.mutate(
{ deviceIds, updateData },
{
onSuccess: () => handleClose(),
}
);
bulkUpdate.mutate({ deviceIds, updateData }, { onSuccess: () => handleClose() });
};

const renderFieldInput = () => {
Expand Down
10 changes: 3 additions & 7 deletions src/vertex/components/features/devices/device-activity-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import React, { useState } from "react";
import { format, parseISO, isValid } from "date-fns";
import { AqCopy01, AqChevronDown, AqChevronUp, AqMonitor } from "@airqo/icons-react";
import { DeviceActivity } from "@/core/apis/devices";
import ReusableToast from "@/components/shared/toast/ReusableToast";
import ReusableButton from "@/components/shared/button/ReusableButton";
import { useClipboard } from "@/core/hooks/useClipboard";

interface DeviceActivityItemProps {
activity: DeviceActivity;
Expand All @@ -28,6 +28,7 @@ const DeviceActivityItem: React.FC<DeviceActivityItemProps> = ({
(typeof activity.description === "string" && /recalled/i.test(activity.description));

const [isPreviousSiteOpen, setIsPreviousSiteOpen] = useState(false);
const { handleCopy } = useClipboard();

return (
<div className="relative pl-14 pb-2">
Expand Down Expand Up @@ -108,12 +109,7 @@ const DeviceActivityItem: React.FC<DeviceActivityItemProps> = ({
<ReusableButton
variant="text"
onClick={async () => {
try {
await navigator.clipboard.writeText(previousSiteId);
ReusableToast({ message: "Copied", type: "SUCCESS" });
} catch {
ReusableToast({ message: "Failed to copy", type: "ERROR" });
}
handleCopy(previousSiteId);
}}
className="p-1"
Icon={AqCopy01}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Copy } from "lucide-react";
import React from "react";
import ReusableToast from "@/components/shared/toast/ReusableToast";
import { useClipboard } from "@/core/hooks/useClipboard";

interface DeviceMeasurementsApiCardProps {
deviceId: string;
}

const DeviceMeasurementsApiCard: React.FC<DeviceMeasurementsApiCardProps> = ({ deviceId }) => {
const { handleCopy } = useClipboard();

return (
<Card className="w-full rounded-lg flex flex-col gap-4 px-3 py-2">
<h2 className="text-lg font-semibold mb-2">Device Measurements API</h2>
Expand All @@ -23,10 +25,7 @@ const DeviceMeasurementsApiCard: React.FC<DeviceMeasurementsApiCardProps> = ({ d
variant="ghost"
size="icon"
className="hover:bg-transparent"
onClick={() => {
navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/recent?token=YOUR_TOKEN`);
ReusableToast({ message: "Copied", type: "SUCCESS" });
}}
onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/recent?token=YOUR_TOKEN`)}
>
<Copy className="w-4 h-4" />
</Button>
Expand All @@ -43,10 +42,7 @@ const DeviceMeasurementsApiCard: React.FC<DeviceMeasurementsApiCardProps> = ({ d
variant="ghost"
size="icon"
className="hover:bg-transparent"
onClick={() => {
navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/historical?token=YOUR_TOKEN`);
ReusableToast({ message: "Copied", type: "SUCCESS" });
}}
onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/historical?token=YOUR_TOKEN`)}
>
<Copy className="w-4 h-4" />
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AlertTriangle } from 'lucide-react';
import ReusableButton from '@/components/shared/button/ReusableButton';
import { useAssignDevicesToCohort } from '@/core/hooks/useCohorts';
import { useQueryClient } from '@tanstack/react-query';
import ReusableToast from '@/components/shared/toast/ReusableToast';
import { useBanner } from '@/context/banner-context';
import { useAppSelector } from '@/core/redux/hooks';
import { useOrphanedDevices } from '@/core/hooks/useDevices';

Expand All @@ -17,6 +17,7 @@ export const OrphanedDevicesAlert: React.FC<OrphanedDevicesAlertProps> = ({ user
const { userDetails } = useAppSelector((state) => state.user);
const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort();
const queryClient = useQueryClient();
const { showBanner } = useBanner();

if (isLoading || !data || !data.devices || data.devices.length === 0) {
return null;
Expand All @@ -26,10 +27,7 @@ export const OrphanedDevicesAlert: React.FC<OrphanedDevicesAlertProps> = ({ user
const cohortId = userDetails?.cohort_ids?.[0];

if (!cohortId) {
ReusableToast({
message: "An issue occurred while setting up your device. Contact support.",
type: "ERROR"
});
showBanner({ severity: 'error', message: 'An issue occurred while setting up your device. Contact support.', scoped: false });
return;
}

Expand Down
18 changes: 2 additions & 16 deletions src/vertex/components/features/grids/admin-levels-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ReusableButton from "@/components/shared/button/ReusableButton";
import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
import { Copy, Edit2, Check, X } from "lucide-react";
import { useBanner } from "@/context/banner-context";
import { useClipboard } from "@/core/hooks/useClipboard";
import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";

interface AdminLevelsModalProps {
Expand Down Expand Up @@ -40,22 +41,7 @@ export function AdminLevelsModal({ isOpen, onClose }: AdminLevelsModalProps) {
}
});

const handleCopyId = async (id: string) => {
try {
await navigator.clipboard.writeText(id);
showBanner({
message: "ID copied to clipboard",
severity: "success",
scoped: true,
});
} catch {
showBanner({
message: "Failed to copy ID",
severity: "error",
scoped: true,
});
}
};
const { handleCopy: handleCopyId } = useClipboard({ successMessage: 'ID copied to clipboard', errorMessage: 'Failed to copy ID', scoped: true });

const startEditing = (id: string, name: string) => {
setEditingId(id);
Expand Down
14 changes: 2 additions & 12 deletions src/vertex/components/features/grids/grid-details-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Loader2 } from "lucide-react";
import ReusableButton from "@/components/shared/button/ReusableButton";
import { AqCopy01, AqEdit01 } from "@airqo/icons-react";
import { Grid } from "@/app/types/grids";
import { useBanner } from "@/context/banner-context";
import { useClipboard } from "@/core/hooks/useClipboard";

interface GridDetailsCardProps {
grid: Grid;
Expand All @@ -15,22 +15,12 @@ interface GridDetailsCardProps {
}

const GridDetailsCard: React.FC<GridDetailsCardProps> = ({ grid, onEdit, loading }) => {
const { showBanner } = useBanner();
const { handleCopy } = useClipboard({ successMessage: 'Copied to clipboard', errorMessage: 'Failed to copy to clipboard' });

if (loading) {
return <Card className="w-full rounded-lg flex flex-col justify-between items-center p-8"><Loader2 className="w-6 h-6 animate-spin" /></Card>;
}

const handleCopy = async (text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
showBanner({ message: "Copied to clipboard", severity: "success", scoped: false });
} catch {
showBanner({ message: "Failed to copy to clipboard", severity: "error", scoped: false });
}
};

return (
<Card className="w-full rounded-lg flex flex-col justify-between">
<div className="px-3 py-2 flex flex-col gap-4">
Expand Down
Loading
Loading