mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
automation
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
type InspectionReportType,
|
||||
type InspectionResultStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
inventoryId: string | null;
|
||||
}
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<InspectionReportType, string> = {
|
||||
INSPECTION: 'Inspection',
|
||||
DAMAGE: 'Damage',
|
||||
WEIGHT_LOSS: 'Weight loss',
|
||||
MISSING_ITEM: 'Missing item',
|
||||
GENERAL: 'General',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<InspectionResultStatus, string> = {
|
||||
PASSED: 'Passed',
|
||||
FAILED: 'Failed',
|
||||
NEEDS_REVIEW: 'Needs review',
|
||||
};
|
||||
|
||||
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
||||
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
||||
const { toast } = useToast();
|
||||
const createReport = useCreateInspectionReport();
|
||||
const uploadAttachments = useUploadInspectionAttachments();
|
||||
|
||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||
const [hasDamage, setHasDamage] = useState(false);
|
||||
const [damageDescription, setDamageDescription] = useState('');
|
||||
const [hasWeightLoss, setHasWeightLoss] = useState(false);
|
||||
const [expectedWeight, setExpectedWeight] = useState<number | ''>('');
|
||||
const [actualWeight, setActualWeight] = useState<number | ''>('');
|
||||
const [hasMissingItems, setHasMissingItems] = useState(false);
|
||||
const [missingItemsDescription, setMissingItemsDescription] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const submitting = createReport.isPending || uploadAttachments.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setReportType('INSPECTION');
|
||||
setInspectionStatus('PASSED');
|
||||
setHasDamage(false);
|
||||
setDamageDescription('');
|
||||
setHasWeightLoss(false);
|
||||
setExpectedWeight('');
|
||||
setActualWeight('');
|
||||
setHasMissingItems(false);
|
||||
setMissingItemsDescription('');
|
||||
setRemarks('');
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const report = await createReport.mutateAsync({
|
||||
inventoryId,
|
||||
payload: {
|
||||
reportType,
|
||||
inspectionStatus,
|
||||
hasDamage,
|
||||
damageDescription: damageDescription.trim() || undefined,
|
||||
hasWeightLoss,
|
||||
expectedWeight: expectedWeight === '' ? undefined : Number(expectedWeight),
|
||||
actualWeight: actualWeight === '' ? undefined : Number(actualWeight),
|
||||
hasMissingItems,
|
||||
missingItemsDescription: missingItemsDescription.trim() || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments.mutateAsync({ reportId: report.id, files });
|
||||
}
|
||||
|
||||
toast({ title: 'Inspection report saved' });
|
||||
reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inspection / Report" centered size="lg">
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Report type"
|
||||
data={INSPECTION_REPORT_TYPES.map((t) => ({ value: t, label: REPORT_TYPE_LABELS[t] }))}
|
||||
value={reportType}
|
||||
onChange={(v) => setReportType((v as InspectionReportType) ?? 'INSPECTION')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label="Inspection status"
|
||||
data={INSPECTION_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
value={inspectionStatus}
|
||||
onChange={(v) => setInspectionStatus((v as InspectionResultStatus) ?? 'PASSED')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" label="Damage" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has damage"
|
||||
checked={hasDamage}
|
||||
onChange={(e) => setHasDamage(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasDamage && (
|
||||
<Textarea
|
||||
mt="xs"
|
||||
label="Damage description"
|
||||
value={damageDescription}
|
||||
onChange={(e) => setDamageDescription(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="md" label="Weight loss" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has weight loss"
|
||||
checked={hasWeightLoss}
|
||||
onChange={(e) => setHasWeightLoss(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasWeightLoss && (
|
||||
<Group grow mt="xs">
|
||||
<NumberInput
|
||||
label="Expected weight (kg)"
|
||||
min={0}
|
||||
value={expectedWeight}
|
||||
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual weight (kg)"
|
||||
min={0}
|
||||
value={actualWeight}
|
||||
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Divider my="md" label="Missing items" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has missing items"
|
||||
checked={hasMissingItems}
|
||||
onChange={(e) => setHasMissingItems(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasMissingItems && (
|
||||
<Textarea
|
||||
mt="xs"
|
||||
label="Missing items description"
|
||||
value={missingItemsDescription}
|
||||
onChange={(e) => setMissingItemsDescription(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="md" />
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
mt="md"
|
||||
label="Images / documents"
|
||||
placeholder="Upload jpg, png or pdf"
|
||||
accept="image/jpeg,image/png,application/pdf"
|
||||
leftSection={<Upload size={16} />}
|
||||
multiple
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
|
||||
Save report
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useStoreInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
@@ -28,6 +29,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const storeMutation = useStoreInventory();
|
||||
const readyMutation = useMarkReadyForLoading();
|
||||
@@ -80,6 +82,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onInspect={setInspectItem}
|
||||
/>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
@@ -94,6 +97,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onClose={() => setHistoryItem(null)}
|
||||
item={historyItem}
|
||||
/>
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectItem)}
|
||||
onClose={() => setInspectItem(null)}
|
||||
inventoryId={inspectItem?.id ?? null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, History } from 'lucide-react';
|
||||
import { ArrowRightLeft, ClipboardList, History } from 'lucide-react';
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
||||
@@ -12,6 +12,7 @@ interface WarehouseInventoryTableProps {
|
||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
@@ -35,6 +36,7 @@ export function WarehouseInventoryTable({
|
||||
onAdvance,
|
||||
onMove,
|
||||
onHistory,
|
||||
onInspect,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -119,6 +121,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<History size={16} />
|
||||
|
||||
@@ -25,3 +25,4 @@ export type { FreightVisualVariant } from './FreightVisual';
|
||||
export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
|
||||
@@ -209,6 +209,11 @@ export const URL_CONSTANTS = {
|
||||
BASE: '/warehouse-inventory',
|
||||
RECEIVE: '/warehouse-inventory/receive',
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
@@ -226,4 +231,9 @@ export const URL_CONSTANTS = {
|
||||
WAREHOUSE_LOADINGS: {
|
||||
BASE: '/warehouse-loadings',
|
||||
},
|
||||
|
||||
WAREHOUSE_INSPECTION: {
|
||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
InspectionReportPayload,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
LoadInventoryPayload,
|
||||
@@ -222,3 +223,58 @@ export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = tr
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
|
||||
|
||||
export function useArrivalQueue() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'arrival-queue'],
|
||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
function useArrivalMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const useAutoUnloadArrived = () =>
|
||||
useArrivalMutation(() => warehouseService.autoUnloadArrived());
|
||||
export const useAutoLoadReady = () => useArrivalMutation(() => warehouseService.autoLoadReady());
|
||||
export const useUnloadBooking = () =>
|
||||
useArrivalMutation((args: { bookingId: string; payload?: Record<string, unknown> }) =>
|
||||
warehouseService.unloadBooking(args.bookingId, args.payload),
|
||||
);
|
||||
|
||||
export function useInspectionReports(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
|
||||
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInspectionReport() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
|
||||
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
|
||||
onSuccess: (_, { inventoryId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadInspectionAttachments() {
|
||||
return useMutation({
|
||||
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
|
||||
warehouseService.uploadInspectionAttachments(reportId, files),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
ArrivalQueueItem,
|
||||
AutoLoadResult,
|
||||
AutoUnloadResult,
|
||||
InspectionAttachment,
|
||||
InspectionReport,
|
||||
InspectionReportPayload,
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
@@ -106,4 +112,37 @@ export const warehouseService = {
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_LOADINGS.BASE, {
|
||||
params: cleanParams(params ?? {}),
|
||||
}),
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||
arrivalQueue: () =>
|
||||
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
apiClient.post<AutoLoadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_LOAD_READY),
|
||||
unloadBooking: (bookingId: string, payload?: Record<string, unknown>) =>
|
||||
apiClient.post<WarehouseInventoryItem>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.UNLOAD_BOOKING(bookingId),
|
||||
payload ?? {},
|
||||
),
|
||||
|
||||
// ── Batch 4.5: Inspection reports ──────────────────────────────────────────
|
||||
listInspectionReports: (inventoryId: string) =>
|
||||
apiClient.get<InspectionReport[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId)),
|
||||
createInspectionReport: (inventoryId: string, payload: InspectionReportPayload) =>
|
||||
apiClient.post<InspectionReport>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId),
|
||||
payload,
|
||||
),
|
||||
getInspectionReport: (id: string) =>
|
||||
apiClient.get<InspectionReport>(URL_CONSTANTS.WAREHOUSE_INSPECTION.BY_ID(id)),
|
||||
uploadInspectionAttachments: (id: string, files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((file) => form.append('files', file));
|
||||
return apiClient.post<InspectionAttachment[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INSPECTION.ATTACHMENTS(id),
|
||||
form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -284,6 +284,81 @@ export interface InventoryInquiryResult {
|
||||
readyForLoadingAt: string | null;
|
||||
}
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
|
||||
|
||||
export interface ArrivalQueueItem {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customer: string | null;
|
||||
cargo: string | null;
|
||||
container: string | null;
|
||||
facility: string | null;
|
||||
warehouse: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
inventoryId: string | null;
|
||||
currentStatus: string | null;
|
||||
arrivalDate: string | null;
|
||||
inspectionStatus: string | null;
|
||||
unloaded: boolean;
|
||||
}
|
||||
|
||||
export interface AutoUnloadResult {
|
||||
processedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface AutoLoadResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export const INSPECTION_REPORT_TYPES = [
|
||||
'INSPECTION',
|
||||
'DAMAGE',
|
||||
'WEIGHT_LOSS',
|
||||
'MISSING_ITEM',
|
||||
'GENERAL',
|
||||
] as const;
|
||||
export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number];
|
||||
|
||||
export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const;
|
||||
export type InspectionResultStatus = (typeof INSPECTION_STATUSES)[number];
|
||||
|
||||
export interface InspectionReportPayload {
|
||||
reportType: InspectionReportType;
|
||||
inspectionStatus: InspectionResultStatus;
|
||||
hasDamage?: boolean;
|
||||
damageDescription?: string;
|
||||
hasWeightLoss?: boolean;
|
||||
expectedWeight?: number;
|
||||
actualWeight?: number;
|
||||
hasMissingItems?: boolean;
|
||||
missingItemsDescription?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface InspectionAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface InspectionReport extends InspectionReportPayload {
|
||||
id: string;
|
||||
inventoryId: string;
|
||||
bookingId: string | null;
|
||||
weightLoss?: number | null;
|
||||
inspectedAt: string | null;
|
||||
createdAt: string;
|
||||
attachments?: InspectionAttachment[];
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaveWarehousePayload {
|
||||
|
||||
Reference in New Issue
Block a user