diff --git a/backend/inventory/tests.py b/backend/inventory/tests.py index 84660b5..27f14ea 100644 --- a/backend/inventory/tests.py +++ b/backend/inventory/tests.py @@ -1050,3 +1050,152 @@ def test_patch_item_box_updates_box_id(self): assert response.status_code == status.HTTP_200_OK self.item_b.refresh_from_db() assert self.item_b.box_id == self.box_b.id + + +# ============================================================================ +# TESTS FOR GET /api/inventory/export/ (CSV Export) +# ============================================================================ + + +@pytest.mark.django_db +class TestExportItems: + """Tests for the CSV export endpoint.""" + + EXPORT_URL = "/api/inventory/export/" + + @pytest.fixture(autouse=True) + def setup(self, client, admin_user, volunteer_user, floor_location, storage_location): + self.client = client + self.admin_user = admin_user + self.volunteer_user = volunteer_user + self.floor_location = floor_location + self.storage_location = storage_location + + # Create a box for filtering tests + self.box = Box.objects.create(box_code="BOX001", label="Export Test Box", location=floor_location) + + # Create test items + self.item_software = CollectionItem.objects.create( + item_code="EXP001", + title="Export Test Game", + platform="SNES", + item_type="SOFTWARE", + current_location=floor_location, + box=self.box, + is_public_visible=True, + ) + self.item_hardware = CollectionItem.objects.create( + item_code="EXP002", + title="Export Test Console", + platform="", + item_type="HARDWARE", + current_location=storage_location, + is_public_visible=True, + ) + + def _get_admin_token(self): + return get_admin_token(self.client) + + def _get_volunteer_token(self): + return get_volunteer_token(self.client) + + def test_unauthenticated_returns_401(self): + """Unauthenticated request should be rejected.""" + response = self.client.get(self.EXPORT_URL) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_admin_gets_csv_response(self): + """Admin should receive a CSV file response.""" + token = self._get_admin_token() + response = self.client.get(self.EXPORT_URL, HTTP_AUTHORIZATION=f"Bearer {token}") + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"] == "text/csv" + assert "attachment" in response["Content-Disposition"] + assert "made_export_" in response["Content-Disposition"] + + def test_volunteer_gets_csv_response(self): + """Volunteer should also have access to export.""" + token = self._get_volunteer_token() + response = self.client.get(self.EXPORT_URL, HTTP_AUTHORIZATION=f"Bearer {token}") + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"] == "text/csv" + + def test_csv_contains_headers_and_data(self): + """CSV should contain header row and data rows.""" + token = self._get_admin_token() + response = self.client.get(self.EXPORT_URL, HTTP_AUTHORIZATION=f"Bearer {token}") + content = response.content.decode("utf-8") + lines = content.strip().split("\n") + + # Header + at least 2 data rows + assert len(lines) >= 3 + assert "MADE ID" in lines[0] + assert "Title" in lines[0] + assert "EXP001" in content + assert "EXP002" in content + + def test_filter_by_record_type(self): + """Filtering by record_type should return only matching items.""" + token = self._get_admin_token() + response = self.client.get( + f"{self.EXPORT_URL}?record_type=SOFTWARE", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + content = response.content.decode("utf-8") + assert "EXP001" in content + assert "EXP002" not in content + + def test_filter_by_box_id(self): + """Filtering by box_id should return only items in that box.""" + token = self._get_admin_token() + response = self.client.get( + f"{self.EXPORT_URL}?box_id={self.box.id}", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + content = response.content.decode("utf-8") + assert "EXP001" in content # In the box + assert "EXP002" not in content # Not in any box + + def test_filter_by_date_range(self): + """Filtering by start_date and end_date should scope results.""" + token = self._get_admin_token() + today = timezone.now().strftime("%Y-%m-%d") + response = self.client.get( + f"{self.EXPORT_URL}?start_date={today}&end_date={today}", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + content = response.content.decode("utf-8") + # Items were created today, so they should appear + assert "EXP001" in content + + def test_filter_future_date_returns_empty(self): + """Filtering with a future start_date should return headers only.""" + token = self._get_admin_token() + future = "2099-01-01" + response = self.client.get( + f"{self.EXPORT_URL}?start_date={future}", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + content = response.content.decode("utf-8") + lines = content.strip().split("\n") + # Only header row + assert len(lines) == 1 + assert "MADE ID" in lines[0] + + def test_invalid_start_date_returns_400(self): + """Invalid date format should return 400.""" + token = self._get_admin_token() + response = self.client.get( + f"{self.EXPORT_URL}?start_date=not-a-date", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_invalid_end_date_returns_400(self): + """Invalid end_date format should return 400.""" + token = self._get_admin_token() + response = self.client.get( + f"{self.EXPORT_URL}?end_date=bad", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/backend/inventory/urls.py b/backend/inventory/urls.py index aa33da7..3a79035 100644 --- a/backend/inventory/urls.py +++ b/backend/inventory/urls.py @@ -5,6 +5,7 @@ PublicCollectionItemViewSet, AdminCollectionItemViewSet, dashboard_stats, + export_items, ) # from .views import InventoryItemViewSet @@ -32,6 +33,7 @@ # PUT /api/inventory/items/{id}/ - Full update # PATCH /api/inventory/items/{id}/ - Partial update # DELETE /api/inventory/items/{id}/ - Soft delete (admin only) +# GET /api/inventory/export/ - Export items as CSV router = DefaultRouter() router.register(r"items", CollectionItemViewSet, basename="item") @@ -43,4 +45,5 @@ path("", include(router.urls)), path("public/", include(public_router.urls)), path("stats/", dashboard_stats, name="dashboard-stats"), + path("export/", export_items, name="export-items"), ] diff --git a/backend/inventory/views.py b/backend/inventory/views.py index ae9af79..84fb1b8 100644 --- a/backend/inventory/views.py +++ b/backend/inventory/views.py @@ -1,3 +1,7 @@ +import csv +from datetime import datetime + +from django.http import HttpResponse from rest_framework import viewsets, permissions, filters, status from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response @@ -163,3 +167,88 @@ def dashboard_stats(request): "total_locations": Location.objects.count(), } ) + + +@api_view(["GET"]) +@permission_classes([IsVolunteer]) +def export_items(request): + """ + Export collection items as a CSV file. + Accepts optional query parameters: + - start_date (YYYY-MM-DD): filter items created on or after this date + - end_date (YYYY-MM-DD): filter items created on or before this date + - box_id (int): filter items belonging to a specific box + - record_type (str): filter by item_type (SOFTWARE, HARDWARE, NON_ELECTRONIC) + """ + queryset = CollectionItem.objects.all().select_related("box", "current_location") + + # Apply filters + start_date = request.query_params.get("start_date") + end_date = request.query_params.get("end_date") + box_id = request.query_params.get("box_id") + record_type = request.query_params.get("record_type") + + if start_date: + try: + parsed = datetime.strptime(start_date, "%Y-%m-%d") + queryset = queryset.filter(created_at__date__gte=parsed.date()) + except ValueError: + return Response( + {"error": "Invalid start_date format. Use YYYY-MM-DD."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if end_date: + try: + parsed = datetime.strptime(end_date, "%Y-%m-%d") + queryset = queryset.filter(created_at__date__lte=parsed.date()) + except ValueError: + return Response( + {"error": "Invalid end_date format. Use YYYY-MM-DD."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if box_id: + queryset = queryset.filter(box__id=box_id) + + if record_type: + queryset = queryset.filter(item_type=record_type) + + # Build CSV response + today = datetime.now().strftime("%Y%m%d") + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="made_export_{today}.csv"' + + writer = csv.writer(response) + writer.writerow( + [ + "MADE ID", + "Title", + "Platform", + "Item Type", + "Box Code", + "Location", + "Location Type", + "Working Condition", + "Status", + "Created At", + ] + ) + + for item in queryset: + writer.writerow( + [ + item.item_code, + item.title, + item.platform, + item.get_item_type_display(), + item.box.box_code if item.box else "", + item.current_location.name if item.current_location else "", + item.current_location.get_location_type_display() if item.current_location else "", + "Yes" if item.working_condition else "No", + item.get_status_display(), + item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "", + ] + ) + + return response diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 164a450..2475f0e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -100,7 +100,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -460,7 +459,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -484,7 +482,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2428,7 +2425,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2513,7 +2511,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2524,7 +2521,6 @@ "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2535,7 +2531,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2585,7 +2580,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -2965,7 +2959,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -3002,7 +2995,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3053,6 +3045,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3184,7 +3177,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3468,7 +3460,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3641,7 +3634,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4351,7 +4343,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4458,237 +4449,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4737,6 +4497,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5029,7 +4790,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5057,7 +4817,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5083,6 +4842,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5098,6 +4858,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5126,7 +4887,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5136,7 +4896,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5149,7 +4908,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5597,7 +5357,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5693,7 +5452,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5793,7 +5551,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/frontend/src/components/items/ExportModal.css b/frontend/src/components/items/ExportModal.css new file mode 100644 index 0000000..41000cf --- /dev/null +++ b/frontend/src/components/items/ExportModal.css @@ -0,0 +1,112 @@ +.export-modal { + background: var(--color-background); + border-radius: var(--radius-lg); + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +} + +.export-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.export-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 28px; + color: var(--color-primary); +} + +.export-modal-body { + padding: var(--spacing-md) var(--spacing-lg); +} + +.export-modal-body .form-group { + margin-bottom: var(--spacing-sm); +} + +.export-modal-body .form-group label { + display: block; + margin-bottom: 4px; + font-size: 13px; + font-weight: 500; + line-height: 18px; + color: var(--color-primary); +} + +.export-modal-body .form-group input, +.export-modal-body .form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: 14px; + line-height: 20px; + font-family: inherit; + background: var(--color-background); + transition: border-color 0.2s; + box-sizing: border-box; +} + +.export-modal-body .form-group input:focus, +.export-modal-body .form-group select:focus { + outline: none; + border-color: var(--color-primary); +} + +.export-date-row { + display: flex; + gap: var(--spacing-sm); +} + +.export-date-row .form-group { + flex: 1; + min-width: 0; +} + +.export-modal-footer { + display: flex; + gap: var(--spacing-sm); + justify-content: flex-end; + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--color-border); + background: var(--color-background-gray); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +.export-modal-description { + margin: 0 0 var(--spacing-md) 0; + font-size: 13px; + line-height: 20px; + color: var(--color-secondary); +} + +.export-error { + margin-bottom: var(--spacing-md); + padding: var(--spacing-sm); + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: var(--radius-md); + color: var(--color-error); + font-size: 13px; + line-height: 20px; +} + +@media (max-width: 600px) { + .export-date-row { + flex-direction: column; + gap: 0; + } + + .export-modal { + max-width: 100%; + margin: var(--spacing-md); + } +} diff --git a/frontend/src/components/items/ExportModal.tsx b/frontend/src/components/items/ExportModal.tsx new file mode 100644 index 0000000..35cb4e6 --- /dev/null +++ b/frontend/src/components/items/ExportModal.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from 'react'; +import { boxesApi } from '../../api/boxes.api'; +import type { Box } from '../../api/boxes.api'; +import apiClient from '../../api/apiClient'; +import './ExportModal.css'; + +interface ExportModalProps { + isOpen: boolean; + onClose: () => void; +} + +const ExportModal: React.FC = ({ isOpen, onClose }) => { + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [boxId, setBoxId] = useState(''); + const [recordType, setRecordType] = useState(''); + const [boxes, setBoxes] = useState([]); + const [isExporting, setIsExporting] = useState(false); + const [error, setError] = useState(null); + + // Fetch boxes for the dropdown + useEffect(() => { + if (isOpen) { + boxesApi.getAll() + .then(setBoxes) + .catch(() => setBoxes([])); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleExport = async () => { + setError(null); + setIsExporting(true); + + try { + const params: Record = {}; + if (startDate) params.start_date = startDate; + if (endDate) params.end_date = endDate; + if (boxId) params.box_id = boxId; + if (recordType) params.record_type = recordType; + + const response = await apiClient.get('/inventory/export/', { + params, + responseType: 'blob', + }); + + // Extract filename from Content-Disposition header or use default + const disposition = response.headers['content-disposition'] || ''; + const filenameMatch = disposition.match(/filename="?(.+?)"?$/); + const filename = filenameMatch ? filenameMatch[1] : 'made_export.csv'; + + // Download the file + const blob = new Blob([response.data], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + handleClose(); + } catch (err) { + console.error('Export failed:', err); + setError('Failed to export data. Please try again.'); + } finally { + setIsExporting(false); + } + }; + + const handleClose = () => { + setStartDate(''); + setEndDate(''); + setBoxId(''); + setRecordType(''); + setError(null); + onClose(); + }; + + return ( +
+
e.stopPropagation()}> +
+

Export Collection Data

+ +
+ +
+

+ Select filters to narrow your export, or leave all fields empty to export the entire collection. +

+ + {error &&
{error}
} + + {/* Date Range */} +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + {/* Box Filter */} +
+ + +
+ + {/* Record Type Filter */} +
+ + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ExportModal; diff --git a/frontend/src/components/items/index.ts b/frontend/src/components/items/index.ts index b5b113d..c846747 100644 --- a/frontend/src/components/items/index.ts +++ b/frontend/src/components/items/index.ts @@ -5,4 +5,5 @@ export { default as ItemList } from './ItemList' export { default as VolunteerList } from '../volunteers/VolunteerList'; export { default as AddItemModal } from './AddItemModal'; export { default as EditItemModal } from './EditItemModal'; -export { default as DeleteItemDialog } from './DeleteItemDialog'; \ No newline at end of file +export { default as DeleteItemDialog } from './DeleteItemDialog'; +export { default as ExportModal } from './ExportModal'; \ No newline at end of file diff --git a/frontend/src/pages/admin/AdminCataloguePage.test.tsx b/frontend/src/pages/admin/AdminCataloguePage.test.tsx index 0723121..7ea4e1d 100644 --- a/frontend/src/pages/admin/AdminCataloguePage.test.tsx +++ b/frontend/src/pages/admin/AdminCataloguePage.test.tsx @@ -15,6 +15,8 @@ vi.mock("../../components/items", () => ({ EditItemModal: ({ isOpen }: { isOpen: boolean }) => isOpen ?
: null, DeleteItemDialog: () => null, + ExportModal: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null, })) beforeEach(() => vi.clearAllMocks()) diff --git a/frontend/src/pages/admin/AdminCataloguePage.tsx b/frontend/src/pages/admin/AdminCataloguePage.tsx index 442a09b..04dde42 100644 --- a/frontend/src/pages/admin/AdminCataloguePage.tsx +++ b/frontend/src/pages/admin/AdminCataloguePage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { AddItemModal, EditItemModal, DeleteItemDialog } from '../../components/items'; +import { AddItemModal, EditItemModal, DeleteItemDialog, ExportModal } from '../../components/items'; import { itemsApi } from '../../api/items.api'; import type { PublicCollectionItem } from '../../lib/types'; import { Link } from 'react-router-dom'; @@ -122,6 +122,7 @@ const AdminCataloguePage: React.FC = () => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); const [selectedItem, setSelectedItem] = useState(null); // Debounce search query @@ -198,32 +199,6 @@ const AdminCataloguePage: React.FC = () => { return true; }); - // Export CSV handler - const handleExportCSV = () => { - const headers = ['Game Title', 'MADE ID', 'System', 'Type', 'Box ID', 'Location', 'Working Condition', 'Status']; - const csvRows = [headers.join(',')]; - items.forEach(item => { - const row = [ - `"${item.title}"`, - item.item_code, - item.platform, - getTypeLabel(item.item_type), - item.box_code, - getLocationLabel(item.location_type, item.location_name), - item.working_condition ? 'Yes' : 'No', - getStatusLabel(item.status) - ]; - csvRows.push(row.join(',')); - }); - const csvContent = csvRows.join('\n'); - const blob = new Blob([csvContent], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'collection_catalogue.csv'; - a.click(); - URL.revokeObjectURL(url); - }; return (
@@ -315,7 +290,7 @@ const AdminCataloguePage: React.FC = () => {
-
@@ -464,6 +439,11 @@ const AdminCataloguePage: React.FC = () => { onConfirm={handleDeleteConfirm} itemTitle={selectedItem?.title || ''} /> + + setIsExportModalOpen(false)} + />
); }; diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index cc44e06..b134d91 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -5,6 +5,7 @@ import { usePendingRequests } from '../../actions/useRequests'; import { useDashboardStats } from '../../actions/useStats'; import type { MovementRequest } from '../../lib/types'; import Button from '../../components/common/Button'; +import { ExportModal } from '../../components/items'; import './AdminDashboard.css'; function formatTimeAgo(dateString: string): string { @@ -25,6 +26,7 @@ const AdminDashboard: React.FC = () => { const { requests: pendingRequests, loading, approve, reject } = usePendingRequests(); const { stats, loading: statsLoading } = useDashboardStats(); const [processingId, setProcessingId] = useState(null); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); const handleApprove = async (request: MovementRequest) => { setProcessingId(request.id); @@ -148,10 +150,16 @@ const AdminDashboard: React.FC = () => { - + + {/* Export Modal */} + setIsExportModalOpen(false)} + /> ); };