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
103 changes: 68 additions & 35 deletions frontend/src/api/services/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,74 @@ export const itemsApi = {
getAllItems: (page: number, limit: number) =>
api.get(`/storage-items?page=${page}&limit=${limit}`),

/**
* Get ordered and filtered items
*/
getOrderedItems: (
ordered_by: ValidItemOrder = "created_at",
ascending: boolean = true,
page: number,
limit: number,
searchquery?: string,
tag_filters?: string[],
activity_filter?: "active" | "inactive",
location_filter?: string[],
categories?: string[],
availability_min?: number,
availability_max?: number,
org_ids?: string[] | string,
) => {
const activity = activity_filter === "active" ? true : false;
let call = `/storage-items/ordered?order=${ordered_by}&page=${page}&limit=${limit}&ascending=${ascending}`;
if (searchquery) call += `&search=${searchquery}`;
if (tag_filters && tag_filters.length > 0)
call += `&tags=${tag_filters.join(",")}`;
if (activity_filter) call += `&active=${activity}`;
if (location_filter && location_filter.length > 0)
call += `&location=${location_filter.join(",")}`;
if (categories && categories.length > 0)
call += `&category=${categories.join(",")}`;
if (availability_min !== undefined)
call += `&availability_min=${availability_min}`;
if (availability_max !== undefined)
call += `&availability_max=${availability_max}`;
if (org_ids && (Array.isArray(org_ids) ? org_ids.length > 0 : true)) {
const orgParam = Array.isArray(org_ids) ? org_ids.join(",") : org_ids;
call += `&org=${orgParam}`;
}
return api.get(call);
},

/**
* Get all items belonging to a specific organization
*/
getAllAdminItems: (
page: number,
limit: number,
ascending: boolean,
ordered_by: ValidItemOrder = "created_at",
searchquery?: string,
tag_filters?: string[],
activity_filter?: "active" | "inactive",
location_filter?: string[],
categories?: string[],
) => {
const activity = activity_filter === "active" ? true : false;
// Backend exposes a protected admin endpoint at /storage-items/ordered-admin-items
// Organization context is sent via the `x-org-id` header by the axios interceptor.
let call = `/storage-items/ordered-admin-items?order=${ordered_by}&page=${page}&limit=${limit}&ascending=${ascending}`;
if (searchquery) call += `&search=${encodeURIComponent(searchquery)}`;
if (tag_filters && tag_filters.length > 0)
call += `&tags=${tag_filters.join(",")}`;
if (activity_filter) call += `&active=${activity}`;
if (location_filter && location_filter.length > 0)
call += `&location=${location_filter.join(",")}`;
if (categories && categories.length > 0)
call += `&category=${categories.join(",")}`;

return api.get(call);
},

/**
* Get a specific item by ID
* @param id - Item ID to fetch
Expand Down Expand Up @@ -105,41 +173,6 @@ export const itemsApi = {
.then((res) => res.data);
},

getOrderedItems: (
ordered_by: ValidItemOrder = "created_at",
ascending: boolean = true,
page: number,
limit: number,
searchquery?: string,
tag_filters?: string[],
activity_filter?: "active" | "inactive",
location_filter?: string[],
categories?: string[],
availability_min?: number,
availability_max?: number,
org_ids?: string[] | string,
) => {
const activity = activity_filter === "active" ? true : false;
let call = `/storage-items/ordered?order=${ordered_by}&page=${page}&limit=${limit}&ascending=${ascending}`;
if (searchquery) call += `&search=${searchquery}`;
if (tag_filters && tag_filters.length > 0)
call += `&tags=${tag_filters.join(",")}`;
if (activity_filter) call += `&active=${activity}`;
if (location_filter && location_filter.length > 0)
call += `&location=${location_filter.join(",")}`;
if (categories && categories.length > 0)
call += `&category=${categories.join(",")}`;
if (availability_min !== undefined)
call += `&availability_min=${availability_min}`;
if (availability_max !== undefined)
call += `&availability_max=${availability_max}`;
if (org_ids && (Array.isArray(org_ids) ? org_ids.length > 0 : true)) {
const orgParam = Array.isArray(org_ids) ? org_ids.join(",") : org_ids;
call += `&org=${orgParam}`;
}
return api.get(call);
},

/**
* Get total amount of bookings in the system (active and inactive)
* @returns number
Expand Down
71 changes: 49 additions & 22 deletions frontend/src/components/Admin/Items/UpdateItemForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,24 @@ import { fetchTagsForItem as fetchTagsForItemAction } from "@/store/slices/tagSl
import { toast } from "sonner";
import { t } from "@/translations";
import { useLanguage } from "@/context/LanguageContext";
import "@/store/utils/validate";
import {
buildCandidateFrom as buildCandidateFromHelper,
validateCandidateWithMessages,
} from "@/utils/updateItemHelpers";
import { Separator } from "@/components/ui/separator";

type Props = {
initialData: Item | null;
editable: boolean;
onSaved?: () => void;
onCancel?: () => void;
onActiveTabChange?: (tab: "details" | "images") => void;
};

const UpdateItemForm: React.FC<Props> = ({
initialData,
editable,
onSaved,
onCancel,
onActiveTabChange,
}) => {
const dispatch = useAppDispatch();
Expand Down Expand Up @@ -90,31 +93,42 @@ const UpdateItemForm: React.FC<Props> = ({
if (selectedTags) setLocalSelectedTags(selectedTags.map((t) => t.id));
}, [selectedTags]);

// Notify parent when active tab changes so parent can enable/disable Edit button
useEffect(() => {
onActiveTabChange?.(activeTab);
}, [activeTab, onActiveTabChange]);

useEffect(() => {
if (!editable) {
setFormData(initialData);
setLocalSelectedTags((selectedTags || []).map((t) => t.id));
setActiveTab("details");
}
}, [editable, initialData, selectedTags]);

if (!formData) return null;

const handleSubmit = async () => {
if (!formData) return;
// If user is editing but still on the details tab, force them to go to images
// to confirm images before allowing save.
if (editable && activeTab === "details") {
// Switch to images tab and inform the user
setActiveTab("images");
toast(
"Please switch to the Images tab and confirm image changes before saving.",
);
return;
}
if (!orgId) return toast.error(t.updateItemForm.messages.missingOrg[lang]);
if (!orgId) return toast.error(t.updateItemForm.messages.missingOrg[lang]);

// Validate using centralized helper
const candidate = buildCandidateFromHelper(
formData,
localSelectedTags,
orgLocations,
);

if (!validateCandidateWithMessages(candidate, lang)) return;

try {
setLoading(true);
// Shape payload to match backend UpdateItem: include tags and location_details
const payload: Partial<{
[k: string]: unknown;
}> = {
Expand All @@ -133,17 +147,27 @@ const UpdateItemForm: React.FC<Props> = ({

await dispatch(fetchTagsForItemAction(String(formData.id))).unwrap();
toast.success(t.updateItemForm.messages.success[lang]);
toast.success(t.updateItemForm.messages.success[lang]);
onSaved?.();
} catch (err) {
console.error(err);
toast.error(t.updateItemForm.messages.error[lang]);
toast.error(t.updateItemForm.messages.error[lang]);
} finally {
setLoading(false);
}
};

const validateBeforeImages = (): boolean => {
if (!formData) return false;

const candidate = buildCandidateFromHelper(
formData,
localSelectedTags,
orgLocations,
);

return validateCandidateWithMessages(candidate, lang);
};

return (
<div>
<div className="flex border-b mb-8">
Expand Down Expand Up @@ -354,16 +378,23 @@ const UpdateItemForm: React.FC<Props> = ({
</div>
</div>

{editable && (
{editable ? (
<div className="flex justify-end mt-4">
<Button
variant="secondary"
onClick={() => setActiveTab("images")}
>
{t.updateItemForm.buttons.goToImages?.[lang] ??
"Proceed to Images"}
</Button>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
onClick={() => {
const ok = validateBeforeImages();
if (ok) setActiveTab("images");
}}
>
{t.updateItemForm.buttons.goToImages?.[lang] ??
"Proceed to Images"}
</Button>
</div>
</div>
) : (
<div className="w-36 h-8"></div>
)}
</div>
) : (
Expand All @@ -374,10 +405,6 @@ const UpdateItemForm: React.FC<Props> = ({

{editable && (
<div className="flex justify-end space-x-2 mt-4">
<Button variant="secondary" onClick={() => onCancel?.()}>
{t.adminItemsTable.messages.deletion.cancel[lang] ??
"Cancel"}
</Button>
<Button
variant={"outline"}
onClick={() => void handleSubmit()}
Expand Down
48 changes: 28 additions & 20 deletions frontend/src/pages/AdminPanel/AdminItemsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ import { Switch } from "@/components/ui/switch";
import { useLanguage } from "@/context/LanguageContext";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import {
fetchOrderedItems,
selectAllItems,
selectItemsError,
selectItemsPagination,
selectItemsLoading,
fetchAllAdminItems,
} from "@/store/slices/itemsSlice";
import { fetchFilteredTags, selectAllTags } from "@/store/slices/tagSlice";
import { t } from "@/translations";
import { Item, ValidItemOrder } from "@/types/item";
import { ColumnDef } from "@tanstack/react-table";
import { Eye, LoaderCircle } from "lucide-react";
import { Eye, LoaderCircle, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import { Command, CommandGroup, CommandItem } from "@/components/ui/command";
import { PaginatedDataTable } from "@/components/ui/data-table-paginated";
Expand All @@ -36,9 +37,7 @@ const AdminItemsTable = () => {
const tagsLoading = useAppSelector((state) => state.tags.loading);
const org_id = useAppSelector(selectActiveOrganizationId);

// Translation
const { lang } = useLanguage();
// filtering states:
const [statusFilter, setStatusFilter] = useState<
"all" | "active" | "inactive"
>(redirectState?.statusFilter ?? "all");
Expand All @@ -57,7 +56,7 @@ const AdminItemsTable = () => {
const [ascending, setAscending] = useState<boolean | null>(
redirectState?.ascending ?? null,
);
const { page, totalPages } = useAppSelector(selectItemsPagination);
const { totalPages } = useAppSelector(selectItemsPagination);
const loading = useAppSelector(selectItemsLoading);
const ITEMS_PER_PAGE = 10;

Expand All @@ -76,19 +75,17 @@ const AdminItemsTable = () => {

/* ————————————————————— Side Effects ———————————————————————————— */
useEffect(() => {
if (!org_id) return;

void dispatch(
fetchOrderedItems({
fetchAllAdminItems({
ordered_by: order,
page: currentPage,
limit: ITEMS_PER_PAGE,
searchquery: debouncedSearchQuery,
ascending: ascending === false ? false : true,
tag_filters: tagFilter,
location_filter: [],
categories: [],
activity_filter: statusFilter !== "all" ? statusFilter : undefined,
// scope to the active organization so admins only see their org's items
org_ids: org_id ? org_id : undefined,
}),
);
}, [
Expand All @@ -97,8 +94,6 @@ const AdminItemsTable = () => {
order,
debouncedSearchQuery,
currentPage,
ITEMS_PER_PAGE,
page,
tagFilter,
statusFilter,
org_id,
Expand Down Expand Up @@ -203,14 +198,27 @@ const AdminItemsTable = () => {
<div className="flex flex-col sm:flex-row justify-between items-center mb-4">
<div className="flex gap-4 items-center">
{/* Search by item name/type */}
<input
type="text"
size={50}
className="w-full text-sm p-2 bg-white rounded-md sm:max-w-md focus:outline-none focus:ring-1 focus:ring-[var(--secondary)] focus:border-[var(--secondary)]"
placeholder={t.adminItemsTable.filters.searchPlaceholder[lang]}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="relative w-full sm:max-w-md bg-white rounded-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<Input
placeholder={t.adminItemsTable.filters.searchPlaceholder[lang]}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && searchTerm) setSearchTerm("");
}}
className="pl-10 pr-9 rounded-md w-full focus:outline-none focus:ring-0 focus:ring-secondary focus:border-secondary focus:bg-white"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>

{/* Filter by active status */}
<select
Expand Down
Loading
Loading