diff --git a/backend/alembic/versions/11c8abf7ef5b_add_session_search_indexes.py b/backend/alembic/versions/11c8abf7ef5b_add_session_search_indexes.py new file mode 100644 index 00000000..f68f7797 --- /dev/null +++ b/backend/alembic/versions/11c8abf7ef5b_add_session_search_indexes.py @@ -0,0 +1,35 @@ +"""add_session_search_indexes + +Revision ID: 11c8abf7ef5b +Revises: 023 +Create Date: 2026-02-07 20:48:18.426932 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '11c8abf7ef5b' +down_revision: Union[str, None] = '023' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add indexes for session search performance + # ticket_number and client_name for ILIKE searches + op.create_index('ix_sessions_ticket_number', 'sessions', ['ticket_number']) + op.create_index('ix_sessions_client_name', 'sessions', ['client_name']) + + # JSONB GIN index for tree_snapshot to speed up tree name searches + op.execute("CREATE INDEX ix_sessions_tree_snapshot_gin ON sessions USING gin (tree_snapshot)") + + +def downgrade() -> None: + # Drop indexes + op.drop_index('ix_sessions_ticket_number', table_name='sessions') + op.drop_index('ix_sessions_client_name', table_name='sessions') + op.drop_index('ix_sessions_tree_snapshot_gin', table_name='sessions') diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index b6d060a9..533640ef 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -23,18 +23,48 @@ async def list_sessions( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], completed: Optional[bool] = Query(None, description="Filter by completion status"), + ticket_number: Optional[str] = Query(None, description="Search by ticket number (partial match)"), + client_name: Optional[str] = Query(None, description="Search by client name (partial match)"), + tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"), + started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"), + started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"), + completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"), + completed_before: Optional[datetime] = Query(None, description="Filter sessions completed before this datetime"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100) ): - """List user's troubleshooting sessions.""" + """List user's troubleshooting sessions with comprehensive filtering.""" query = select(Session).where(Session.user_id == current_user.id) + # Completion status filter if completed is not None: if completed: query = query.where(Session.completed_at.isnot(None)) else: query = query.where(Session.completed_at.is_(None)) + # Ticket number search (case-insensitive partial match) + if ticket_number: + query = query.where(Session.ticket_number.ilike(f"%{ticket_number}%")) + + # Client name search (case-insensitive partial match) + if client_name: + query = query.where(Session.client_name.ilike(f"%{client_name}%")) + + # Tree name filter (JSONB path query) + if tree_name: + query = query.where(Session.tree_snapshot['name'].astext.ilike(f"%{tree_name}%")) + + # Date range filters + if started_after: + query = query.where(Session.started_at >= started_after) + if started_before: + query = query.where(Session.started_at <= started_before) + if completed_after: + query = query.where(Session.completed_at >= completed_after) + if completed_before: + query = query.where(Session.completed_at <= completed_before) + query = query.order_by(Session.started_at.desc()) query = query.offset(skip).limit(limit) @@ -97,11 +127,19 @@ async def start_session( detail="You don't have access to this tree" ) - # Create session with tree snapshot + # Create session with tree snapshot (includes tree metadata for filtering/export) + tree_snapshot = { + **tree.tree_structure, + "name": tree.name, + "description": tree.description, + "category": tree.category, + "version": tree.version + } + new_session = Session( tree_id=tree.id, user_id=current_user.id, - tree_snapshot=tree.tree_structure, + tree_snapshot=tree_snapshot, path_taken=[], decisions=[], ticket_number=session_data.ticket_number, diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index daddcf12..f6a3b687 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -721,3 +721,212 @@ class TestSessions: headers=admin_auth_headers ) assert response.status_code == 201 + + @pytest.mark.asyncio + async def test_filter_sessions_by_ticket_number( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test filtering sessions by ticket number (partial match).""" + # Create sessions with different ticket numbers + await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "INC-12345"}, + headers=auth_headers + ) + await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "REQ-67890"}, + headers=auth_headers + ) + + # Filter by ticket number + response = await client.get( + "/api/v1/sessions?ticket_number=INC", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["ticket_number"] == "INC-12345" + + @pytest.mark.asyncio + async def test_filter_sessions_by_client_name( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test filtering sessions by client name (partial match, case-insensitive).""" + # Create sessions with different clients + await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "client_name": "Acme Corporation"}, + headers=auth_headers + ) + await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "client_name": "TechStart Inc"}, + headers=auth_headers + ) + + # Filter by client name (case-insensitive partial match) + response = await client.get( + "/api/v1/sessions?client_name=acme", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["client_name"] == "Acme Corporation" + + @pytest.mark.asyncio + async def test_filter_sessions_by_tree_name( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test filtering sessions by tree name from snapshot.""" + # Create session (tree_snapshot includes tree name) + response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + assert response.status_code == 201 + + # Filter by tree name (partial match from snapshot) + tree_name_part = test_tree["name"][:5] # First 5 chars + response = await client.get( + f"/api/v1/sessions?tree_name={tree_name_part}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert test_tree["name"] in data[0]["tree_snapshot"]["name"] + + @pytest.mark.asyncio + async def test_filter_sessions_by_started_date_range( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test filtering sessions by started date range.""" + from datetime import datetime, timezone, timedelta + from urllib.parse import quote + + # Create a session + response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "TEST-001"}, + headers=auth_headers + ) + assert response.status_code == 201 + + # Get current time and create date range + now = datetime.now(timezone.utc) + yesterday = now - timedelta(days=1) + tomorrow = now + timedelta(days=1) + + # Filter by started date range (should include the session) + # URL encode the datetime strings + started_after = quote(yesterday.isoformat()) + started_before = quote(tomorrow.isoformat()) + + response = await client.get( + f"/api/v1/sessions?started_after={started_after}&started_before={started_before}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert data[0]["ticket_number"] == "TEST-001" + + @pytest.mark.asyncio + async def test_filter_sessions_by_completed_date_range( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test filtering sessions by completed date range.""" + from datetime import datetime, timezone, timedelta + from urllib.parse import quote + + # Create and complete a session + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "TEST-002"}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + await client.post( + f"/api/v1/sessions/{session_id}/complete", + headers=auth_headers + ) + + # Get current time and create date range + now = datetime.now(timezone.utc) + yesterday = now - timedelta(days=1) + tomorrow = now + timedelta(days=1) + + # Filter by completed date range + completed_after = quote(yesterday.isoformat()) + completed_before = quote(tomorrow.isoformat()) + + response = await client.get( + f"/api/v1/sessions?completed_after={completed_after}&completed_before={completed_before}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert any(s["ticket_number"] == "TEST-002" for s in data) + + @pytest.mark.asyncio + async def test_filter_sessions_combined( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test combining multiple filters (AND logic).""" + # Create sessions with various attributes + await client.post( + "/api/v1/sessions", + json={ + "tree_id": test_tree["id"], + "ticket_number": "INC-111", + "client_name": "Client A" + }, + headers=auth_headers + ) + await client.post( + "/api/v1/sessions", + json={ + "tree_id": test_tree["id"], + "ticket_number": "INC-222", + "client_name": "Client B" + }, + headers=auth_headers + ) + + # Filter by both ticket and client (should match only one) + response = await client.get( + "/api/v1/sessions?ticket_number=INC-111&client_name=Client A", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["ticket_number"] == "INC-111" + assert data[0]["client_name"] == "Client A" + + @pytest.mark.asyncio + async def test_filter_sessions_no_results( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that filtering returns empty list when no matches.""" + response = await client.get( + "/api/v1/sessions?ticket_number=NONEXISTENT-999", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 24a295ec..afd31ab7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,12 +12,15 @@ "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "immer": "^11.1.3", "lucide-react": "^0.563.0", "react": "^19.2.0", + "react-day-picker": "^9.13.1", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zundo": "^2.3.0", "zustand": "^5.0.10" @@ -335,6 +338,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1554,6 +1563,7 @@ "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2418,6 +2428,23 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, "node_modules/debug": { @@ -2475,18 +2502,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3441,18 +3456,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3544,280 +3547,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "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" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5030,6 +4759,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.13.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.1.tgz", + "integrity": "sha512-9nx2lBBJ0VZw5jJekId3DishwnJLiqY1Me1JvCrIyqbWwcflBTVaEkiK+w1bre5oMNWYo722eu+8UAMXWMqktw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -5342,6 +5092,16 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 672da33f..1bb27c80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,12 +14,15 @@ "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "immer": "^11.1.3", "lucide-react": "^0.563.0", "react": "^19.2.0", + "react-day-picker": "^9.13.1", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zundo": "^2.3.0", "zustand": "^5.0.10" diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 7be667cf..00109422 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -6,6 +6,13 @@ export interface SessionListParams { size?: number tree_id?: string completed?: boolean + ticket_number?: string + client_name?: string + tree_name?: string + started_after?: string // ISO datetime string + started_before?: string + completed_after?: string + completed_before?: string } export interface SessionListResponse { diff --git a/frontend/src/components/session/SessionFilters.tsx b/frontend/src/components/session/SessionFilters.tsx new file mode 100644 index 00000000..4d47f34e --- /dev/null +++ b/frontend/src/components/session/SessionFilters.tsx @@ -0,0 +1,316 @@ +import { useState, useEffect } from 'react' +import { Search, Calendar, X, Filter } from 'lucide-react' +import { DayPicker } from 'react-day-picker' +import type { DateRange } from 'react-day-picker' +import { format, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, subDays } from 'date-fns' +import 'react-day-picker/dist/style.css' +import { cn } from '@/lib/utils' +import type { TreeListItem } from '@/types' + +export interface SessionFilterState { + ticketNumber: string + clientName: string + treeName: string + dateRange: DateRange | undefined + dateType: 'started' | 'completed' +} + +interface SessionFiltersProps { + filters: SessionFilterState + onChange: (filters: SessionFilterState) => void + onClear: () => void + trees: TreeListItem[] +} + +const datePresets = [ + { label: 'Today', value: 'today' }, + { label: 'This Week', value: 'week' }, + { label: 'Last 7 Days', value: 'last7' }, + { label: 'This Month', value: 'month' }, +] + +export function SessionFilters({ filters, onChange, onClear, trees }: SessionFiltersProps) { + const [showDatePicker, setShowDatePicker] = useState(false) + const [localDateRange, setLocalDateRange] = useState(filters.dateRange) + + useEffect(() => { + setLocalDateRange(filters.dateRange) + }, [filters.dateRange]) + + const handleFilterChange = (key: keyof SessionFilterState, value: any) => { + onChange({ ...filters, [key]: value }) + } + + const applyDatePreset = (preset: string) => { + const today = new Date() + let range: DateRange | undefined + + switch (preset) { + case 'today': + range = { from: startOfDay(today), to: endOfDay(today) } + break + case 'week': + range = { from: startOfWeek(today), to: endOfWeek(today) } + break + case 'last7': + range = { from: subDays(today, 7), to: today } + break + case 'month': + range = { from: startOfMonth(today), to: endOfMonth(today) } + break + } + + if (range) { + setLocalDateRange(range) + onChange({ ...filters, dateRange: range }) + setShowDatePicker(false) + } + } + + const clearDateRange = () => { + setLocalDateRange(undefined) + onChange({ ...filters, dateRange: undefined }) + } + + const formatDateRange = (range: DateRange | undefined) => { + if (!range?.from) return 'Select date range' + if (!range.to) return format(range.from, 'MMM d, yyyy') + return `${format(range.from, 'MMM d')} - ${format(range.to, 'MMM d, yyyy')}` + } + + const hasActiveFilters = + filters.ticketNumber || + filters.clientName || + filters.treeName || + filters.dateRange?.from + + const uniqueTreeNames = Array.from(new Set(trees.map(t => t.name))).sort() + + return ( +
+ {/* Main Filter Controls */} +
+ {/* Ticket Number Search */} +
+ + handleFilterChange('ticketNumber', e.target.value)} + className={cn( + 'w-full rounded-md border border-input bg-background py-2 pl-9 pr-3', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' + )} + /> +
+ + {/* Client Name Search */} +
+ + handleFilterChange('clientName', e.target.value)} + className={cn( + 'w-full rounded-md border border-input bg-background py-2 pl-9 pr-3', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' + )} + /> +
+ + {/* Tree Name Filter */} + +
+ + {/* Date Range Filter */} +
+
+ + + {showDatePicker && ( +
+ {/* Date Type Toggle */} +
+ + +
+ + {/* Quick Presets */} +
+ {datePresets.map((preset) => ( + + ))} +
+ + {/* Date Picker */} + { + setLocalDateRange(range) + if (range?.from && range?.to) { + onChange({ ...filters, dateRange: range }) + setShowDatePicker(false) + } + }} + className="rdp-custom" + /> + + {/* Actions */} +
+ + +
+
+ )} +
+ + {/* Clear All Button */} + {hasActiveFilters && ( + + )} +
+ + {/* Active Filter Chips */} + {hasActiveFilters && ( +
+ Active filters: + {filters.ticketNumber && ( + + Ticket: {filters.ticketNumber} + + + )} + {filters.clientName && ( + + Client: {filters.clientName} + + + )} + {filters.treeName && ( + + Tree: {filters.treeName} + + + )} + {filters.dateRange?.from && ( + + {formatDateRange(filters.dateRange)} ({filters.dateType}) + + + )} +
+ )} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 580f7aa4..58f59910 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -140,3 +140,128 @@ @apply active:scale-[0.98] transition-transform; } } + +/* Sonner Toast Customization - ResolutionFlow Design System */ +@layer components { + /* Base toast styling matching Modal/Card components */ + :where([data-sonner-toast]) { + @apply bg-card text-card-foreground; + @apply border border-border shadow-lg; + @apply rounded-lg; + font-family: 'Inter', system-ui, sans-serif; + } + + /* Toast title using heading font */ + :where([data-sonner-toast]) [data-title] { + font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + font-weight: 600; + } + + /* Success toast - uses primary brand color */ + :where([data-sonner-toast][data-type="success"]) { + @apply border-primary/30; + } + :where([data-sonner-toast][data-type="success"]) [data-icon] { + @apply text-primary; + } + + /* Error toast - uses destructive color */ + :where([data-sonner-toast][data-type="error"]) { + @apply border-destructive/30; + } + :where([data-sonner-toast][data-type="error"]) [data-icon] { + @apply text-destructive; + } + + /* Info toast - uses muted color */ + :where([data-sonner-toast][data-type="info"]) { + @apply border-border; + } + :where([data-sonner-toast][data-type="info"]) [data-icon] { + @apply text-muted-foreground; + } + + /* Warning toast - uses amber color */ + :where([data-sonner-toast][data-type="warning"]) { + border-color: hsl(38 92% 50% / 0.3); + } + :where([data-sonner-toast][data-type="warning"]) [data-icon] { + color: hsl(38 92% 50%); + } + + /* Close button matching Modal close button */ + :where([data-sonner-toast]) [data-close-button] { + @apply text-muted-foreground hover:bg-accent hover:text-accent-foreground; + @apply rounded-md transition-colors; + } + + /* Loading spinner uses primary color */ + :where([data-sonner-toast]) [data-icon][data-loading] { + @apply text-primary; + } + + /* React Day Picker Customization - ResolutionFlow Design System */ + .rdp-custom { + @apply text-foreground; + } + + .rdp-custom .rdp-month { + @apply w-full; + } + + .rdp-custom .rdp-caption { + @apply flex justify-center items-center mb-4; + } + + .rdp-custom .rdp-caption_label { + @apply text-sm font-medium; + } + + .rdp-custom .rdp-nav { + @apply flex gap-1; + } + + .rdp-custom .rdp-nav_button { + @apply h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100; + } + + .rdp-custom .rdp-table { + @apply w-full border-collapse; + } + + .rdp-custom .rdp-head_cell { + @apply text-muted-foreground font-normal text-xs; + } + + .rdp-custom .rdp-cell { + @apply text-center text-sm p-0; + } + + .rdp-custom .rdp-day { + @apply h-9 w-9 p-0 font-normal hover:bg-accent rounded-md transition-colors; + } + + .rdp-custom .rdp-day_selected { + @apply bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground; + } + + .rdp-custom .rdp-day_today { + @apply bg-accent text-accent-foreground; + } + + .rdp-custom .rdp-day_outside { + @apply text-muted-foreground opacity-50; + } + + .rdp-custom .rdp-day_disabled { + @apply text-muted-foreground opacity-50; + } + + .rdp-custom .rdp-day_range_middle { + @apply bg-accent text-accent-foreground; + } + + .rdp-custom .rdp-day_hidden { + @apply invisible; + } +} diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 9e65e1f9..9e5612db 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -1,45 +1,150 @@ import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { sessionsApi } from '@/api' -import type { Session } from '@/types' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { sessionsApi, treesApi } from '@/api' +import type { Session, TreeListItem } from '@/types' +import type { DateRange } from 'react-day-picker' +import { SessionFilters } from '@/components/session/SessionFilters' +import type { SessionFilterState } from '@/components/session/SessionFilters' import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' export function SessionHistoryPage() { const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const [sessions, setSessions] = useState([]) + const [trees, setTrees] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all') + // Initialize filters from URL params + const [filters, setFilters] = useState(() => { + const ticketNumber = searchParams.get('ticket') || '' + const clientName = searchParams.get('client') || '' + const treeName = searchParams.get('tree') || '' + const dateType = (searchParams.get('dateType') || 'started') as 'started' | 'completed' + + const from = searchParams.get('from') + const to = searchParams.get('to') + const dateRange: DateRange | undefined = + from && to ? { from: new Date(from), to: new Date(to) } : undefined + + return { + ticketNumber, + clientName, + treeName, + dateRange, + dateType, + } + }) + + // Load trees for filter dropdown + useEffect(() => { + const loadTrees = async () => { + try { + const treesData = await treesApi.list({}) + setTrees(treesData) + } catch (err) { + console.error('Failed to load trees:', err) + } + } + loadTrees() + }, []) + + // Load sessions when filters change useEffect(() => { loadSessions() - }, [filter]) + }, [filter, filters]) + + // Update URL params when filters change + useEffect(() => { + const params = new URLSearchParams() + + if (filters.ticketNumber) params.set('ticket', filters.ticketNumber) + if (filters.clientName) params.set('client', filters.clientName) + if (filters.treeName) params.set('tree', filters.treeName) + if (filters.dateRange?.from) { + params.set('from', filters.dateRange.from.toISOString()) + params.set('to', (filters.dateRange.to || filters.dateRange.from).toISOString()) + params.set('dateType', filters.dateType) + } + + setSearchParams(params, { replace: true }) + }, [filters, setSearchParams]) const loadSessions = async () => { setIsLoading(true) - setError(null) try { - const params = filter === 'all' ? {} : { completed: filter === 'completed' } + const params: any = {} + + // Tab filter (all/active/completed) + if (filter !== 'all') { + params.completed = filter === 'completed' + } + + // Search/filter params + if (filters.ticketNumber) { + params.ticket_number = filters.ticketNumber + } + if (filters.clientName) { + params.client_name = filters.clientName + } + if (filters.treeName) { + params.tree_name = filters.treeName + } + + // Date range params + if (filters.dateRange?.from) { + const fromDate = filters.dateRange.from + const toDate = filters.dateRange.to || filters.dateRange.from + + if (filters.dateType === 'started') { + params.started_after = fromDate.toISOString() + params.started_before = toDate.toISOString() + } else { + params.completed_after = fromDate.toISOString() + params.completed_before = toDate.toISOString() + } + } + const sessionsData = await sessionsApi.list(params) setSessions(sessionsData) } catch (err) { - setError('Failed to load sessions') + toast.error('Failed to load sessions') console.error(err) } finally { setIsLoading(false) } } + const handleFilterChange = (newFilters: SessionFilterState) => { + setFilters(newFilters) + } + + const handleClearFilters = () => { + setFilters({ + ticketNumber: '', + clientName: '', + treeName: '', + dateRange: undefined, + dateType: 'started', + }) + } + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString() } + const getTreeName = (session: Session): string => { + return session.tree_snapshot?.name || 'Unknown Tree' + } + return (

Session History

- View and manage your troubleshooting sessions + Search and filter your troubleshooting sessions

@@ -61,12 +166,15 @@ export function SessionHistoryPage() { ))}
- {/* Error State */} - {error && ( -
- {error} -
- )} + {/* Search and Filter Controls */} +
+ +
{/* Loading State */} {isLoading ? ( @@ -76,12 +184,21 @@ export function SessionHistoryPage() { ) : sessions.length === 0 ? (
No sessions found.{' '} - + {filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from ? ( + + ) : ( + + )}
) : (
@@ -91,8 +208,9 @@ export function SessionHistoryPage() { className="rounded-lg border border-border bg-card p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md" >
-
-
+
+ {/* Status and Ticket/Client */} +
{session.client_name && ( - - · {session.client_name} + + {session.client_name} )}
+ + {/* Tree Name */} +

+ Tree: {getTreeName(session)} +

+ + {/* Timestamps */}

Started: {formatDate(session.started_at)} {session.completed_at && ( <> · Completed: {formatDate(session.completed_at)} )}

+ + {/* Stats */}

- {session.decisions.length} decisions recorded + {session.decisions.length} decision{session.decisions.length !== 1 ? 's' : ''} recorded + {session.scratchpad && session.scratchpad.trim() && ( + · Has notes + )}

+ + {/* Actions */}