Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 41 additions & 46 deletions frontend/app/(dashboard)/transfers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,13 @@ import { Search, Check, X, ArrowRightLeft, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";

interface Transfer {
id: string;
assetName: string;
assetId?: string;
from: string;
to: string;
requester: string;
requesterId: string;
status: "PENDING" | "APPROVED" | "REJECTED" | "COMPLETED" | "CANCELLED";
reason?: string;
rejectionReason?: string;
createdAt: string;
}

const MOCK_TRANSFERS: Transfer[] = [
{ id: "tr-1", assetName: "MacBook Pro M2", from: "Engineering", to: "Design", requester: "Alex Johnson", requesterId: "u1", status: "PENDING", reason: "Needed for design sprint", createdAt: "2026-08-20" },
{ id: "tr-2", assetName: "Dell UltraSharp Monitor", from: "Marketing", to: "Sales", requester: "Sam Lee", requesterId: "u2", status: "APPROVED", createdAt: "2026-08-18" },
{ id: "tr-3", assetName: "Standing Desk", from: "Operations", to: "Engineering", requester: "Jordan Kim", requesterId: "u3", status: "REJECTED", rejectionReason: "Not available", createdAt: "2026-08-15" },
];
import {
useTransfers,
useApproveTransfer,
useRejectTransfer,
useCancelTransfer,
} from "@/lib/query/hooks/useTransfers";
import { TransferStatus } from "@/lib/api/transfers";

const STATUS_COLORS: Record<string, string> = {
PENDING: "bg-yellow-100 text-yellow-700",
Expand All @@ -38,29 +25,39 @@ type Tab = "pending" | "my-requests" | "history";

export default function TransfersPage() {
const [tab, setTab] = useState<Tab>("pending");
const [transfers, setTransfers] = useState<Transfer[]>(MOCK_TRANSFERS);
const [rejectTarget, setRejectTarget] = useState<Transfer | null>(null);
const [rejectTarget, setRejectTarget] = useState<{ id: string } | null>(null);
const [rejectReason, setRejectReason] = useState("");

const pending = transfers.filter((t) => t.status === "PENDING");
const myRequests = transfers.filter((t) => t.requesterId === "u1"); // current user
const history = transfers.filter((t) => t.status !== "PENDING");
const { data, isLoading } = useTransfers({ page: 1, limit: 100 });
const approveMutation = useApproveTransfer();
const rejectMutation = useRejectTransfer();
const cancelMutation = useCancelTransfer();

const transfers = data?.items ?? [];
const pending = transfers.filter((t) => t.status === TransferStatus.PENDING);
const history = transfers.filter((t) => t.status !== TransferStatus.PENDING);

const currentList = tab === "pending" ? pending : tab === "my-requests" ? myRequests : history;
const currentList = tab === "pending" ? pending : history;

const handleApprove = (id: string) => {
setTransfers((prev) => prev.map((t) => t.id === id ? { ...t, status: "APPROVED" as const } : t));
approveMutation.mutate(id);
};

const handleReject = () => {
if (!rejectTarget) return;
setTransfers((prev) => prev.map((t) => t.id === rejectTarget.id ? { ...t, status: "REJECTED" as const, rejectionReason: rejectReason } : t));
setRejectTarget(null);
setRejectReason("");
rejectMutation.mutate(
{ id: rejectTarget.id, data: { reason: rejectReason } },
{
onSuccess: () => {
setRejectTarget(null);
setRejectReason("");
},
}
);
};

const handleCancel = (id: string) => {
setTransfers((prev) => prev.map((t) => t.id === id ? { ...t, status: "CANCELLED" as const } : t));
cancelMutation.mutate(id);
};

return (
Expand All @@ -78,11 +75,6 @@ export default function TransfersPage() {
<Clock className="w-3.5 h-3.5" /> Pending
{pending.length > 0 && <span className="ml-1 bg-yellow-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">{pending.length}</span>}
</button>
<button onClick={() => setTab("my-requests")} className={`px-4 py-2 rounded-md text-sm font-medium ${
tab === "my-requests" ? "bg-white shadow text-gray-900" : "text-gray-500 hover:text-gray-700"
}`}>
My Requests
</button>
<button onClick={() => setTab("history")} className={`px-4 py-2 rounded-md text-sm font-medium ${
tab === "history" ? "bg-white shadow text-gray-900" : "text-gray-500 hover:text-gray-700"
}`}>
Expand All @@ -92,7 +84,11 @@ export default function TransfersPage() {

{/* Transfers list */}
<div className="space-y-3">
{currentList.length === 0 ? (
{isLoading ? (
<div className="bg-white border rounded-xl p-12 text-center">
<p className="text-gray-500">Loading transfers...</p>
</div>
) : currentList.length === 0 ? (
<div className="bg-white border rounded-xl p-12 text-center">
<ArrowRightLeft className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">No transfers to show</p>
Expand All @@ -103,30 +99,29 @@ export default function TransfersPage() {
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-gray-900">{t.assetName}</p>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[t.status]}`}>
<p className="font-medium text-gray-900">Asset {t.assetId.slice(0, 8)}</p>
<Badge variant="outline" className={STATUS_COLORS[t.status]}>
{t.status}
</span>
</Badge>
</div>
<p className="text-sm text-gray-500">
{t.from} → {t.to} · Requested by {t.requester} · {new Date(t.createdAt).toLocaleDateString()}
{t.fromDepartmentId.slice(0, 8)} → {t.toDepartmentId.slice(0, 8)} · {new Date(t.createdAt).toLocaleDateString()}
</p>
{t.reason && <p className="text-xs text-gray-400 mt-1">Reason: {t.reason}</p>}
{t.rejectionReason && <p className="text-xs text-red-500 mt-1">Rejection reason: {t.rejectionReason}</p>}
</div>
<div className="flex gap-2">
{tab === "pending" && (
<>
<Button size="sm" onClick={() => handleApprove(t.id)}>
<Button size="sm" onClick={() => handleApprove(t.id)} disabled={approveMutation.isPending}>
<Check className="w-3 h-3 mr-1" /> Approve
</Button>
<Button variant="outline" size="sm" onClick={() => setRejectTarget(t)}>
<Button variant="outline" size="sm" onClick={() => setRejectTarget({ id: t.id })} disabled={rejectMutation.isPending}>
<X className="w-3 h-3 mr-1" /> Reject
</Button>
</>
)}
{tab === "my-requests" && t.status === "PENDING" && (
<Button variant="outline" size="sm" onClick={() => handleCancel(t.id)}>Cancel</Button>
{tab === "history" && t.status === TransferStatus.PENDING && (
<Button variant="outline" size="sm" onClick={() => handleCancel(t.id)} disabled={cancelMutation.isPending}>Cancel</Button>
)}
</div>
</div>
Expand Down
60 changes: 60 additions & 0 deletions frontend/lib/api/transfers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { api } from '../api';

export enum TransferStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}

export interface AssetTransfer {
id: string;
assetId: string;
fromDepartmentId: string;
toDepartmentId: string;
requestedByUserId: string;
approvedByUserId?: string;
status: TransferStatus;
rejectionReason?: string;
createdAt: string;
updatedAt: string;
}

export interface TransferListResponse {
items: AssetTransfer[];
total: number;
page: number;
limit: number;
totalPages: number;
}

export interface TransferListFilters {
page?: number;
limit?: number;
search?: string;
}

export interface RejectTransferInput {
reason: string;
}

export const transferApiClient = {
getTransfers: (filters?: TransferListFilters): Promise<TransferListResponse> =>
api.get<TransferListResponse>('/transfers', { params: filters }).then((r) => r.data),

getTransfer: (id: string): Promise<AssetTransfer> =>
api.get<AssetTransfer>(`/transfers/${id}`).then((r) => r.data),

approveTransfer: (id: string): Promise<AssetTransfer> =>
api.post<AssetTransfer>(`/transfers/${id}/approve`).then((r) => r.data),

rejectTransfer: (id: string, data: RejectTransferInput): Promise<AssetTransfer> =>
api.post<AssetTransfer>(`/transfers/${id}/reject`, data).then((r) => r.data),

cancelTransfer: (id: string): Promise<AssetTransfer> =>
api.post<AssetTransfer>(`/transfers/${id}/cancel`).then((r) => r.data),

completeTransfer: (id: string): Promise<AssetTransfer> =>
api.post<AssetTransfer>(`/transfers/${id}/complete`).then((r) => r.data),
};
66 changes: 66 additions & 0 deletions frontend/lib/query/hooks/useTransfers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
useQuery,
useMutation,
useQueryClient,
UseQueryOptions,
} from '@tanstack/react-query';
import {
transferApiClient,
TransferListFilters,
TransferListResponse,
AssetTransfer,
RejectTransferInput,
} from '@/lib/api/transfers';
import { queryKeys } from '../keys';
import { ApiError } from '../types';

export function useTransfers(
filters?: TransferListFilters,
options?: Omit<
UseQueryOptions<TransferListResponse, ApiError>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<TransferListResponse, ApiError>({
queryKey: queryKeys.transfers.list((filters as Record<string, unknown>) ?? {}),
queryFn: () => transferApiClient.getTransfers(filters),
...options,
});
}

export function useTransfer(id: string) {
return useQuery<AssetTransfer, ApiError>({
queryKey: queryKeys.transfers.detail(id),
queryFn: () => transferApiClient.getTransfer(id),
});
}

export function useApproveTransfer() {
const queryClient = useQueryClient();
return useMutation<AssetTransfer, ApiError, string>({
mutationFn: (id) => transferApiClient.approveTransfer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.transfers.all });
},
});
}

export function useRejectTransfer() {
const queryClient = useQueryClient();
return useMutation<AssetTransfer, ApiError, { id: string; data: RejectTransferInput }>({
mutationFn: ({ id, data }) => transferApiClient.rejectTransfer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.transfers.all });
},
});
}

export function useCancelTransfer() {
const queryClient = useQueryClient();
return useMutation<AssetTransfer, ApiError, string>({
mutationFn: (id) => transferApiClient.cancelTransfer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.transfers.all });
},
});
}
6 changes: 6 additions & 0 deletions frontend/lib/query/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,10 @@ export const queryKeys = {
lists: () => [...queryKeys.purchaseOrders.all, 'list'] as const,
detail: (id: string) => [...queryKeys.purchaseOrders.all, 'detail', id] as const,
},
transfers: {
all: ['transfers'] as const,
lists: () => [...queryKeys.transfers.all, 'list'] as const,
list: (filters: Record<string, unknown>) => [...queryKeys.transfers.lists(), filters] as const,
detail: (id: string) => [...queryKeys.transfers.all, 'detail', id] as const,
},
} as const;
Loading