diff --git a/.gitignore b/.gitignore index 0d5c84a69..c6e5710e7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,8 +57,11 @@ instance/ # Sphinx documentation docs/_build/ -# Planning artefacts (Claude Code plan-mode files; local-only) -docs/plans/ +# Planning artefacts (Claude Code plan-mode files; local-only). +# The pattern must match the contents, not the directory: git never descends +# into an excluded directory, which would leave the negation unreachable. +docs/plans/* +!docs/plans/_template.md # PyBuilder target/ @@ -275,3 +278,6 @@ session-replays/ docs/.codesight/ frontend/.codesight/ *.tsv + +# Local state for scripts/write_google_doc.py (per-instance runtime data) +scripts/google_doc_manifest.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b90360605..dc49282b5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,32 +27,6 @@ "panel": "dedicated" } }, - { - "label": "Frontend Manual Dev Server", - "type": "process", - "command": "${env:HOME}/.nvm/nvm-exec", - "args": ["npm", "run", "manual:dev"], - "hide": true, - "options": { - "cwd": "${workspaceFolder}/frontend" - }, - "isBackground": true, - "problemMatcher": { - "owner": "vitepress", - "pattern": { - "regexp": "^$" - }, - "background": { - "activeOnStart": true, - "beginsPattern": "vitepress", - "endsPattern": "http://localhost" - } - }, - "presentation": { - "reveal": "always", - "panel": "dedicated" - } - }, { "label": "Ngrok Tunnels", "type": "shell", @@ -123,7 +97,6 @@ "label": "Start Dev Environment", "dependsOn": [ "Frontend Dev Server", - "Frontend Manual Dev Server", "Ngrok Tunnels", "Celery Worker", "Celery Beat" diff --git a/CLAUDE.md b/CLAUDE.md index 9f09dd3a0..831fe7257 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,10 +120,15 @@ ADJUSTMENT entries (kind='adjust'): fixes when that is the pragmatic path, but direct-to-main commits are banned. - Before committing, check the current branch. If it is `main`, create or switch to a branch first. -- Do not leave uncommitted changes behind at the end of a task. If the change is - complete and scoped, commit it on the current branch. If the scope is unclear, - mixed with unrelated work, or the user may not want it committed, ask before - committing. +- Commits are all-or-nothing for the worktree: either do not commit at all, or + commit every tracked change together. Never use a path-limited commit. +- Do not leave uncommitted changes behind at the end of a task. Finish and + verify incomplete work before committing the whole worktree. +- `docs/plans/` is ephemeral scratch — one plan per piece of work, gitignored + (except `_template.md`). Delete a plan when its PR is opened, having first + migrated anything durable to its real home: open work → the Jira ticket, tools + → `scripts/`, decisions → an ADR. Never leave a non-plan artifact (script, data + file) sitting in `docs/plans/` — it goes through that same migrate-or-delete gate. - Run focused tests for touched code when useful. Do not manually run expensive hook commands like `bash scripts/check_mypy.sh`, `npm run test:unit`, `npm run lint`, `npm run type-check`, or frontend builds unless diagnosing a hook failure; they run automatically during `git commit`/`git push`. - Tests must protect enduring behaviour, invariants, or algorithms. Never assert the implementation's own text — `assertIn` on source code, a CLI flag or log string, or source line ordering — which mirrors the code, breaks on every refactor, and catches no bug. Execute the code path and assert the observable outcome: return value, exit code, output, or resulting state. diff --git a/apps/accounts/tests/test_staff_api.py b/apps/accounts/tests/test_staff_api.py index 9286b9971..0a116c5c2 100644 --- a/apps/accounts/tests/test_staff_api.py +++ b/apps/accounts/tests/test_staff_api.py @@ -50,3 +50,31 @@ def test_staff_list_prefetches_groups_for_serializer(self): and "accounts_staff_groups" in query["sql"].lower() ] self.assertEqual(len(group_queries), 1) + + +class StaffDetailAPIViewTests(BaseTestCase): + def test_staff_cannot_be_deleted_via_api(self) -> None: + """Staff are offboarded by setting date_left, never deleted. The detail + endpoint must reject DELETE so a hard delete (which would orphan or be + blocked by protected time entries) can't be reintroduced.""" + office_user = Staff.objects.create_user( + email="office@example.test", + password="testpass", + first_name="Office", + last_name="User", + is_office_staff=True, + ) + target = Staff.objects.create_user( + email="leaver@example.test", + password="testpass", + first_name="Depa", + last_name="Rting", + ) + + client = APIClient() + client.force_authenticate(user=office_user) + + response = client.delete(f"/api/accounts/staff/{target.id}/") + + self.assertEqual(response.status_code, 405) + self.assertTrue(Staff.objects.filter(pk=target.id).exists()) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index b21128259..9a538761b 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -4,7 +4,7 @@ from apps.accounts.views.password_views import SecurityPasswordChangeView from apps.accounts.views.staff_api import ( StaffListCreateAPIView, - StaffRetrieveUpdateDestroyAPIView, + StaffRetrieveUpdateAPIView, ) from apps.accounts.views.staff_views import ( StaffListAPIView, @@ -39,7 +39,7 @@ path("staff/", StaffListCreateAPIView.as_view(), name="api_staff_list_create"), path( "staff//", - StaffRetrieveUpdateDestroyAPIView.as_view(), + StaffRetrieveUpdateAPIView.as_view(), name="api_staff_detail", ), ] diff --git a/apps/accounts/views/__init__.py b/apps/accounts/views/__init__.py index 6d24435e9..8e2f2e068 100644 --- a/apps/accounts/views/__init__.py +++ b/apps/accounts/views/__init__.py @@ -10,7 +10,7 @@ from django.apps import apps if apps.ready: - from .staff_api import StaffListCreateAPIView, StaffRetrieveUpdateDestroyAPIView + from .staff_api import StaffListCreateAPIView, StaffRetrieveUpdateAPIView except (ImportError, RuntimeError): # Django not ready or circular import, skip conditional imports pass @@ -23,6 +23,6 @@ "SecurityPasswordChangeView", "StaffListAPIView", "StaffListCreateAPIView", - "StaffRetrieveUpdateDestroyAPIView", + "StaffRetrieveUpdateAPIView", "get_staff_rates", ] diff --git a/apps/accounts/views/staff_api.py b/apps/accounts/views/staff_api.py index 8ba3aeec0..6c1b00ea4 100644 --- a/apps/accounts/views/staff_api.py +++ b/apps/accounts/views/staff_api.py @@ -87,10 +87,11 @@ def post(self, request, *args, **kwargs): @extend_schema( - summary="Retrieve, update, or delete staff member", - description="API endpoint for retrieving, updating, and deleting individual staff members. " - "Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. " - "Includes comprehensive logging for update operations and handles multipart/form data for file uploads.", + summary="Retrieve or update staff member", + description="API endpoint for retrieving and updating individual staff members. " + "Supports GET (retrieve) and PUT/PATCH (update). " + "Includes comprehensive logging for update operations and handles multipart/form data for file uploads. " + "Staff are not deleted; offboarding is done by setting date_left.", tags=["Staff Management"], examples=[ OpenApiExample( @@ -110,12 +111,15 @@ def post(self, request, *args, **kwargs): ), ], ) -class StaffRetrieveUpdateDestroyAPIView(generics.RetrieveUpdateDestroyAPIView): - """API endpoint for retrieving, updating, and deleting individual staff members. +class StaffRetrieveUpdateAPIView(generics.RetrieveUpdateAPIView[Staff]): + """API endpoint for retrieving and updating individual staff members. - Supports GET (retrieve), PUT/PATCH (update), and DELETE operations on - specific staff members. Includes comprehensive logging for update operations - and handles multipart/form data for file uploads. + Supports GET (retrieve) and PUT/PATCH (update) on specific staff members. + Includes comprehensive logging for update operations and handles + multipart/form data for file uploads. + + Staff are never deleted (their time entries are protected); offboarding is + done by setting date_left. """ queryset = Staff.objects.all() diff --git a/apps/job/tests/_pdf_golden_fixtures.py b/apps/job/tests/_pdf_golden_fixtures.py index 9a2e8b5f7..6aa2b5b07 100644 --- a/apps/job/tests/_pdf_golden_fixtures.py +++ b/apps/job/tests/_pdf_golden_fixtures.py @@ -25,6 +25,7 @@ from apps.accounts.models import Staff from apps.company.models import Company, Person +from apps.crm.models import PhoneEndpoint from apps.job.models import CostLine, Job, JobEvent, JobFile, LabourSubtype from apps.workflow.models import CompanyDefaults, XeroPayItem @@ -73,6 +74,17 @@ def build_golden_job(test_staff: Staff) -> Job: company.starting_job_number = STARTING_JOB_NUMBER company.save() + # The letterhead prints the shop's main-line number + # (workshop_pdf_service._primary_company_endpoint_number). Create it here + # so PDF output stays byte-identical regardless of which seed fixtures a + # caller happens to load — this builder is the single source of truth for + # every field that influences the rendered PDF. + PhoneEndpoint.objects.create( + number="+6496365131", + label="Main line", + endpoint_type=PhoneEndpoint.EndpointType.MAIN_LINE, + ) + company = Company.objects.create( name="ACME Engineering Ltd", xero_last_modified=FROZEN_NOW, diff --git a/apps/quoting/services/ai_price_extraction.py b/apps/quoting/services/ai_price_extraction.py index da7dc371d..df1dee301 100644 --- a/apps/quoting/services/ai_price_extraction.py +++ b/apps/quoting/services/ai_price_extraction.py @@ -1,51 +1,38 @@ -import abc import logging from typing import Any, Dict, Optional, Tuple from apps.workflow.enums import AIProviderTypes -from .providers.gemini_provider import GeminiPriceExtractionProvider +from .providers.base import PriceExtractionProvider +from .providers.gemini_provider import ( + GEMINI_FLASH_MODEL, + GeminiPriceExtractionProvider, +) # from .providers.claude_provider import ClaudePriceExtractionProvider -from .providers.mistral_provider import MistralPriceExtractionProvider +from .providers.mistral_provider import ( + MISTRAL_OCR_MODEL, + MistralPriceExtractionProvider, +) logger = logging.getLogger(__name__) -class PriceExtractionProvider(abc.ABC): - """Abstract base class for AI price extraction providers.""" - - provider_name: str - - @abc.abstractmethod - def extract_price_data( - self, file_path: str, content_type: Optional[str] = None - ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - """ - Extract price data from a supplier price list file. - - Args: - file_path: Path to the price list file - content_type: MIME type of the file - - Returns: - Tuple containing extracted data dict and error message if any - """ - - class PriceExtractionFactory: """Factory for creating AI price extraction providers.""" @staticmethod def create_provider( - provider_type: str, api_key: str, model_name: str = None + provider_type: str, api_key: str, model_name: str | None = None ) -> PriceExtractionProvider: """Create a provider instance based on type.""" if provider_type == AIProviderTypes.MISTRAL: - return MistralPriceExtractionProvider(api_key) + return MistralPriceExtractionProvider( + api_key, model_name or MISTRAL_OCR_MODEL + ) elif provider_type == AIProviderTypes.GOOGLE: return GeminiPriceExtractionProvider( - api_key, model_name or "gemini-2.0-flash-exp" + api_key, model_name or GEMINI_FLASH_MODEL ) # elif provider_type == AIProviderTypes.ANTHROPIC: # return ClaudePriceExtractionProvider(api_key) diff --git a/apps/quoting/services/pdf_data_validation.py b/apps/quoting/services/pdf_data_validation.py index be03be4f3..a85e58f73 100644 --- a/apps/quoting/services/pdf_data_validation.py +++ b/apps/quoting/services/pdf_data_validation.py @@ -16,9 +16,9 @@ class PDFDataValidationService: and duplicate detection for supplier products. """ - def __init__(self): - self.validation_errors = [] - self.warnings = [] + def __init__(self) -> None: + self.validation_errors: List[str] = [] + self.warnings: List[str] = [] def validate_extracted_data( self, data: Dict[str, Any] diff --git a/apps/quoting/services/providers/base.py b/apps/quoting/services/providers/base.py new file mode 100644 index 000000000..cd9970be8 --- /dev/null +++ b/apps/quoting/services/providers/base.py @@ -0,0 +1,24 @@ +import abc +from typing import Any, Dict, Optional, Tuple + + +class PriceExtractionProvider(abc.ABC): + """Abstract base class for AI price extraction providers.""" + + provider_name: str + model_name: str + + @abc.abstractmethod + def extract_price_data( + self, file_path: str, content_type: Optional[str] = None + ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """ + Extract price data from a supplier price list file. + + Args: + file_path: Path to the price list file + content_type: MIME type of the file + + Returns: + Tuple containing extracted data dict and error message if any + """ diff --git a/apps/quoting/services/providers/gemini_provider.py b/apps/quoting/services/providers/gemini_provider.py index 289771b17..0e52526f7 100644 --- a/apps/quoting/services/providers/gemini_provider.py +++ b/apps/quoting/services/providers/gemini_provider.py @@ -12,17 +12,20 @@ from apps.workflow.models import CompanyDefaults +from .base import PriceExtractionProvider from .common import clean_json_response, create_extraction_prompt, log_token_usage logger = logging.getLogger(__name__) +GEMINI_FLASH_MODEL = "gemini-flash-latest" -class GeminiPriceExtractionProvider: + +class GeminiPriceExtractionProvider(PriceExtractionProvider): """Gemini AI provider for price extraction from PDF documents.""" provider_name = "Gemini" - def __init__(self, api_key: str, model_name: str = "gemini-2.5-flash"): + def __init__(self, api_key: str, model_name: str = GEMINI_FLASH_MODEL): self.api_key = api_key self.model_name = model_name @@ -30,7 +33,7 @@ def extract_price_data( self, file_path: str, content_type: Optional[str] = None ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """ - Extract price data from a supplier price list PDF using Gemini 2.5 Flash. + Extract price data from a supplier price list PDF using Gemini Flash. Args: file_path: Path to the PDF file @@ -140,7 +143,7 @@ def _process_extracted_data( "total_lines": len(str(raw_data).split("\n")), "items_found": len(processed_items), "pages_processed": 1, # Gemini processes the entire PDF at once - "extraction_method": "Gemini 2.5 Flash", + "extraction_method": f"Gemini ({self.model_name})", }, } @@ -420,7 +423,9 @@ def _extract_from_multiple_pages( "total_lines": len(str(all_items).split("\n")), "items_found": len(all_items), "pages_processed": num_pages, - "extraction_method": "Gemini 2.5 Flash (Page-by-page)", + "extraction_method": ( + f"Gemini ({self.model_name}, page-by-page)" + ), }, } logger.info( diff --git a/apps/quoting/services/providers/mistral_provider.py b/apps/quoting/services/providers/mistral_provider.py index a864725c5..15714db7f 100644 --- a/apps/quoting/services/providers/mistral_provider.py +++ b/apps/quoting/services/providers/mistral_provider.py @@ -8,8 +8,34 @@ from mistralai.client.sdk import Mistral +from .base import PriceExtractionProvider + logger = logging.getLogger(__name__) +MISTRAL_OCR_MODEL = "mistral-ocr-latest" + + +def _format_dimensions(parsed: Dict[str, Optional[str]]) -> str: + """Render parsed dimensions as the display string the importer stores. + + PDFDataValidationService writes `dimensions` straight to a text column via + _clean_text, which stringifies whatever it is given — so anything but a + string here lands in the database as its Python repr. + """ + sized = [ + part + for part in (parsed["thickness"], parsed["width"], parsed["length"]) + if part + ] + if sized: + return " x ".join(sized) + + # Nothing sheet- or tube-shaped was parsed; round stock carries a diameter. + diameter = parsed["diameter"] + if not diameter: + return "" + return f"dia {diameter}" + def encode_pdf(pdf_path): """Encode the PDF file to base64.""" @@ -21,13 +47,14 @@ def encode_pdf(pdf_path): return None -class MistralPriceExtractionProvider: +class MistralPriceExtractionProvider(PriceExtractionProvider): """Mistral AI provider for price extraction using OCR""" provider_name = "Mistral" - def __init__(self, api_key: str): + def __init__(self, api_key: str, model_name: str = MISTRAL_OCR_MODEL): self.api_key = api_key + self.model_name = model_name def _extract_supplier_from_text(self, text: str) -> str: """Extract supplier name from the OCR text.""" @@ -173,19 +200,19 @@ def _extract_products_from_markdown_tables( # Create variant ID from description variant_id = description.replace(" ", "_").replace("/", "_")[:100] + # Field names here are a contract with + # PDFDataValidationService._sanitize_single_product — it + # reads item_no, price_unit and a string dimensions, and + # silently drops anything named differently. product = { "description": description, - "supplier_item_code": item_code, + "item_no": item_code, "variant_id": variant_id, "unit_price": unit_price, + "price_unit": "each", "category": current_category, "specifications": dimensions["specifications"], - "dimensions": { - "width": dimensions["width"], - "length": dimensions["length"], - "thickness": dimensions.get("thickness"), - "diameter": dimensions.get("diameter"), - }, + "dimensions": _format_dimensions(dimensions), "product_name": ( f"{current_category} - {description}" if current_category @@ -305,7 +332,7 @@ def extract_price_data( raise ValueError("Failed to encode PDF file") # Process the document with OCR ocr_response = client.ocr.process( - model="mistral-ocr-latest", + model=self.model_name, document={ "type": "document_url", "document_url": f"data:application/pdf;base64,{base64_pdf}", diff --git a/apps/quoting/tests/test_ai_price_extraction.py b/apps/quoting/tests/test_ai_price_extraction.py new file mode 100644 index 000000000..3d40e734d --- /dev/null +++ b/apps/quoting/tests/test_ai_price_extraction.py @@ -0,0 +1,72 @@ +from django.test import SimpleTestCase + +from apps.quoting.services.ai_price_extraction import PriceExtractionFactory +from apps.quoting.services.providers.base import PriceExtractionProvider +from apps.quoting.services.providers.gemini_provider import ( + GEMINI_FLASH_MODEL, + GeminiPriceExtractionProvider, +) +from apps.quoting.services.providers.mistral_provider import ( + MISTRAL_OCR_MODEL, + MistralPriceExtractionProvider, +) +from apps.workflow.enums import AIProviderTypes + + +class GeminiModelSelectionTests(SimpleTestCase): + def test_provider_defaults_to_rolling_flash_alias(self) -> None: + provider = GeminiPriceExtractionProvider("test-api-key") + + self.assertEqual(provider.model_name, GEMINI_FLASH_MODEL) + + def test_factory_uses_rolling_flash_alias_when_model_is_not_configured( + self, + ) -> None: + provider = PriceExtractionFactory.create_provider( + AIProviderTypes.GOOGLE, + "test-api-key", + "", + ) + + self.assertIsInstance(provider, GeminiPriceExtractionProvider) + self.assertEqual(provider.model_name, GEMINI_FLASH_MODEL) + + def test_factory_preserves_an_explicit_gemini_model(self) -> None: + provider = PriceExtractionFactory.create_provider( + AIProviderTypes.GOOGLE, + "test-api-key", + "gemini-pro-latest", + ) + + self.assertEqual(provider.model_name, "gemini-pro-latest") + + +class ProviderContractTests(SimpleTestCase): + """Every provider the factory can return honours the shared contract.""" + + def test_factory_returns_a_provider_with_a_model_name(self) -> None: + for provider_type in (AIProviderTypes.GOOGLE, AIProviderTypes.MISTRAL): + with self.subTest(provider_type=provider_type): + provider = PriceExtractionFactory.create_provider( + provider_type, + "test-api-key", + "", + ) + + self.assertIsInstance(provider, PriceExtractionProvider) + self.assertTrue(provider.provider_name) + self.assertTrue(provider.model_name) + + def test_mistral_defaults_to_the_rolling_ocr_alias(self) -> None: + provider = MistralPriceExtractionProvider("test-api-key") + + self.assertEqual(provider.model_name, MISTRAL_OCR_MODEL) + + def test_factory_preserves_an_explicit_mistral_model(self) -> None: + provider = PriceExtractionFactory.create_provider( + AIProviderTypes.MISTRAL, + "test-api-key", + "mistral-ocr-2505", + ) + + self.assertEqual(provider.model_name, "mistral-ocr-2505") diff --git a/apps/quoting/tests/test_ocr_fixtures.py b/apps/quoting/tests/test_ocr_fixtures.py index a7f98531c..a0ff3d19c 100644 --- a/apps/quoting/tests/test_ocr_fixtures.py +++ b/apps/quoting/tests/test_ocr_fixtures.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch +from apps.quoting.services.pdf_data_validation import PDFDataValidationService from apps.quoting.services.providers.mistral_provider import ( MistralPriceExtractionProvider, ) @@ -24,6 +25,26 @@ def _ocr_response(self): ) return SimpleNamespace(pages=[page]) + def _ocr_response_with_item_code(self) -> SimpleNamespace: + """As above, but with a supplier item code in the description. + + The main fixture's descriptions carry no code, so item_no is legitimately + empty there and cannot show whether the code survives the import. + """ + page = SimpleNamespace( + markdown=( + "Customer: | Morris Sheetmetal |\n" + "Date: | 2026-05-22 |\n\n" + "# Aluminium Sheet\n\n" + "| Description | Price |\n" + "| --- | --- |\n" + "| UA1130 1.2mm x 1200 x 2400 5005 Sheet | $71.07 |\n\n" + "**WM Aluminium Ltd**" + ), + text="", + ) + return SimpleNamespace(pages=[page]) + @patch("apps.quoting.services.providers.mistral_provider.Mistral") def test_price_parsing_with_mocked_ocr_response(self, mock_mistral_class): """Catches OCR parser drift without making a live Mistral API call.""" @@ -71,21 +92,55 @@ def test_price_parsing_with_mocked_ocr_response(self, mock_mistral_class): first_item, { "description": "1.2mm x 1200 x 2400 5005 Sheet", - "supplier_item_code": "", + "item_no": "", "variant_id": "1.2mm_x_1200_x_2400_5005_Sheet", "unit_price": 71.07, + "price_unit": "each", "category": "Aluminium Sheet", "specifications": "1.2mm x 1200 x 2400 5005 Sheet", - "dimensions": { - "width": "1200", - "length": "2400", - "thickness": "1.2mm", - "diameter": None, - }, + "dimensions": "1.2mm x 1200 x 2400", "product_name": ("Aluminium Sheet - 1.2mm x 1200 x 2400 5005 Sheet"), }, ) + @patch("apps.quoting.services.providers.mistral_provider.Mistral") + def test_extracted_items_survive_the_import_sanitiser( + self, mock_mistral_class: Mock + ) -> None: + """The import sanitiser must keep the fields Mistral extracts. + + The provider names its fields for + PDFDataValidationService._sanitize_single_product, and a mismatch is + silent — the field is simply absent from the sanitised product. So this + asserts across that boundary, on the last hop before import, rather + than on the provider's own dict, which would agree with itself after a + rename. + """ + mock_client = Mock() + mock_client.ocr.process.return_value = self._ocr_response_with_item_code() + mock_mistral_class.return_value = mock_client + provider = MistralPriceExtractionProvider(api_key="dummy_key_for_testing") + + with ( + patch( + "apps.quoting.services.providers.mistral_provider.os.path.exists", + return_value=True, + ), + patch( + "apps.quoting.services.providers.mistral_provider.encode_pdf", + return_value="mock_base64", + ), + ): + result, error = provider.extract_price_data("mock_file_path.pdf") + + self.assertIsNone(error) + assert result is not None + + sanitised = PDFDataValidationService().sanitize_product_data(result["items"]) + + self.assertEqual(sanitised[0]["item_no"], "UA1130") + self.assertEqual(sanitised[0]["dimensions"], "1.2mm x 1200 x 2400") + if __name__ == "__main__": unittest.main() diff --git a/apps/workflow/__init__.py b/apps/workflow/__init__.py index 3308392b5..405847d8b 100644 --- a/apps/workflow/__init__.py +++ b/apps/workflow/__init__.py @@ -1,7 +1,7 @@ # This file is autogenerated by update_init.py script from .apps import WorkflowConfig, check_company_defaults_field_sections -from .enums import AIProviderTypes +from .enums import AIProviderTypes, NotebookLmRestriction from .exceptions import ( NoValidXeroTokenError, XeroQuotaFloorReached, @@ -46,6 +46,7 @@ GroupedAppErrorSerializer, GroupedErrorResolveRequestSerializer, GroupedErrorResolveResponseSerializer, + NotebookLmLinkSerializer, SessionReplayChunkCreateSerializer, SessionReplayChunkSerializer, SessionReplayEventsResponseSerializer, @@ -118,6 +119,8 @@ "JWTAuthentication", "LoginRequiredMiddleware", "NoValidXeroTokenError", + "NotebookLmLinkSerializer", + "NotebookLmRestriction", "PasswordStrengthMiddleware", "SearchTelemetryClickRequestSerializer", "SearchTelemetryClickResponseSerializer", diff --git a/apps/workflow/enums.py b/apps/workflow/enums.py index 14aae7002..3d54921b7 100644 --- a/apps/workflow/enums.py +++ b/apps/workflow/enums.py @@ -6,3 +6,8 @@ class AIProviderTypes(models.TextChoices): GOOGLE = "Gemini" MISTRAL = "Mistral" OPENAI = "OpenAI" + + +class NotebookLmRestriction(models.TextChoices): + NONE = "none", "All staff" + SUPERUSER = "superuser", "Superusers only" diff --git a/apps/workflow/fixtures/ai_providers.json.example b/apps/workflow/fixtures/ai_providers.json.example index 1c43d054d..1237c1fa1 100644 --- a/apps/workflow/fixtures/ai_providers.json.example +++ b/apps/workflow/fixtures/ai_providers.json.example @@ -17,7 +17,7 @@ "name": "Gemini", "provider_type": "Gemini", "api_key": "YOUR_GEMINI_KEY_HERE", - "model_name": "gemini-2.5-flash", + "model_name": "gemini-flash-latest", "default": false } }, diff --git a/apps/workflow/migrations/0012_use_latest_gemini_flash_model.py b/apps/workflow/migrations/0012_use_latest_gemini_flash_model.py new file mode 100644 index 000000000..1da7e4cb0 --- /dev/null +++ b/apps/workflow/migrations/0012_use_latest_gemini_flash_model.py @@ -0,0 +1,41 @@ +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +DEPRECATED_GEMINI_MODELS = ( + "gemini-2.0-flash-exp", + "gemini-2.5-flash", +) +GEMINI_FLASH_MODEL = "gemini-flash-latest" + + +def use_latest_gemini_flash_model( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + AIProvider = apps.get_model("workflow", "AIProvider") + AIProvider.objects.filter( + provider_type="Gemini", + model_name__in=DEPRECATED_GEMINI_MODELS, + ).update(model_name=GEMINI_FLASH_MODEL) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow", "0011_companydefaults_xero_sales_branding_theme_id"), + ] + + operations = [ + migrations.AlterField( + model_name="aiprovider", + name="model_name", + field=models.CharField( + blank=True, + help_text="Model name (e.g., gemini-flash-latest)", + max_length=100, + ), + ), + migrations.RunPython( + use_latest_gemini_flash_model, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/workflow/migrations/0013_notebooklmlink.py b/apps/workflow/migrations/0013_notebooklmlink.py new file mode 100644 index 000000000..bfdcc83b1 --- /dev/null +++ b/apps/workflow/migrations/0013_notebooklmlink.py @@ -0,0 +1,56 @@ +# Generated by Django 6.0.7 on 2026-07-23 00:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("workflow", "0012_use_latest_gemini_flash_model"), + ] + + operations = [ + migrations.CreateModel( + name="NotebookLmLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(help_text="Menu item name", max_length=100)), + ("url", models.URLField(help_text="NotebookLM notebook URL")), + ( + "enabled", + models.BooleanField( + default=True, help_text="Show this link in the training menu" + ), + ), + ( + "restriction", + models.CharField( + choices=[ + ("none", "All staff"), + ("superuser", "Superusers only"), + ], + default="none", + help_text="Who may see this link in the menu", + max_length=20, + ), + ), + ( + "order", + models.IntegerField(default=0, help_text="Menu display order"), + ), + ], + options={ + "verbose_name": "NotebookLM Link", + "verbose_name_plural": "NotebookLM Links", + "ordering": ["order", "name"], + }, + ), + ] diff --git a/apps/workflow/models/__init__.py b/apps/workflow/models/__init__.py index f839fbda0..78b1ae453 100644 --- a/apps/workflow/models/__init__.py +++ b/apps/workflow/models/__init__.py @@ -3,6 +3,7 @@ from .ai_provider import AIProvider from .app_error import AppError, XeroError from .company_defaults import CompanyDefaults +from .notebook_lm_link import NotebookLmLink from .search_telemetry_event import SearchTelemetryEvent from .service_api_key import ServiceAPIKey from .session_replay import SessionReplayChunk, SessionReplayRecording @@ -16,6 +17,7 @@ "AIProvider", "AppError", "CompanyDefaults", + "NotebookLmLink", "SearchTelemetryEvent", "ServiceAPIKey", "SessionReplayChunk", diff --git a/apps/workflow/models/ai_provider.py b/apps/workflow/models/ai_provider.py index f8f4dfb31..82a7291bb 100644 --- a/apps/workflow/models/ai_provider.py +++ b/apps/workflow/models/ai_provider.py @@ -13,7 +13,7 @@ class AIProvider(models.Model): ) model_name = models.CharField( max_length=100, - help_text="Specific model name (e.g., gemini-2.5-flash-lite-preview-06-17)", + help_text="Model name (e.g., gemini-flash-latest)", blank=True, ) provider_type = models.CharField( diff --git a/apps/workflow/models/notebook_lm_link.py b/apps/workflow/models/notebook_lm_link.py new file mode 100644 index 000000000..49e8fab9e --- /dev/null +++ b/apps/workflow/models/notebook_lm_link.py @@ -0,0 +1,33 @@ +from django.db import models + +from apps.workflow.enums import NotebookLmRestriction + + +class NotebookLmLink(models.Model): + """A NotebookLM notebook link shown in the app's training menu. + + Per-instance and admin-managed: each client configures their own rows. + `restriction` decides which staff see the link in the navbar; it is a UX + filter, not an access boundary (NotebookLM access is enforced by Drive ACLs). + """ + + name = models.CharField(max_length=100, help_text="Menu item name") + url = models.URLField(help_text="NotebookLM notebook URL") + enabled = models.BooleanField( + default=True, help_text="Show this link in the training menu" + ) + restriction = models.CharField( + max_length=20, + choices=NotebookLmRestriction, + default=NotebookLmRestriction.NONE, + help_text="Who may see this link in the menu", + ) + order = models.IntegerField(default=0, help_text="Menu display order") + + def __str__(self) -> str: + return self.name + + class Meta: + ordering = ["order", "name"] + verbose_name = "NotebookLM Link" + verbose_name_plural = "NotebookLM Links" diff --git a/apps/workflow/serializers.py b/apps/workflow/serializers.py index d52346c03..c8964aef0 100644 --- a/apps/workflow/serializers.py +++ b/apps/workflow/serializers.py @@ -9,6 +9,7 @@ AIProvider, AppError, CompanyDefaults, + NotebookLmLink, XeroAccount, XeroApp, XeroError, @@ -31,6 +32,21 @@ def _build_logo_url( return request.build_absolute_uri(field_file.url) +class NotebookLmLinkSerializer(serializers.ModelSerializer[NotebookLmLink]): + """Serializer for NotebookLM training-menu links (read + write).""" + + class Meta: + model = NotebookLmLink + fields = ( + "id", + "name", + "url", + "enabled", + "restriction", + "order", + ) + + class AIProviderSerializer(serializers.ModelSerializer): """ Serializer for reading AIProvider instances. diff --git a/apps/workflow/tests/test_latest_gemini_model_migration.py b/apps/workflow/tests/test_latest_gemini_model_migration.py new file mode 100644 index 000000000..6e4d01af0 --- /dev/null +++ b/apps/workflow/tests/test_latest_gemini_model_migration.py @@ -0,0 +1,61 @@ +from typing import ClassVar + +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + + +class LatestGeminiModelMigrationTests(TransactionTestCase): + migrate_from: ClassVar[tuple[tuple[str, str], ...]] = ( + ("workflow", "0011_companydefaults_xero_sales_branding_theme_id"), + ) + migrate_to: ClassVar[tuple[tuple[str, str], ...]] = ( + ("workflow", "0012_use_latest_gemini_flash_model"), + ) + + def setUp(self) -> None: + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.old_apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self) -> None: + self.executor.loader.build_graph() + self.executor.migrate(self.executor.loader.graph.leaf_nodes()) + super().tearDown() + + def test_deprecated_gemini_models_move_to_rolling_alias(self) -> None: + AIProvider = self.old_apps.get_model("workflow", "AIProvider") + obsolete_stable = AIProvider.objects.create( + name="Gemini Stable", + provider_type="Gemini", + model_name="gemini-2.5-flash", + ) + obsolete_preview = AIProvider.objects.create( + name="Gemini Preview", + provider_type="Gemini", + model_name="gemini-2.0-flash-exp", + ) + explicit_alias = AIProvider.objects.create( + name="Gemini Pro", + provider_type="Gemini", + model_name="gemini-pro-latest", + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + new_apps = self.executor.loader.project_state(self.migrate_to).apps + AIProvider = new_apps.get_model("workflow", "AIProvider") + + self.assertEqual( + AIProvider.objects.get(pk=obsolete_stable.pk).model_name, + "gemini-flash-latest", + ) + self.assertEqual( + AIProvider.objects.get(pk=obsolete_preview.pk).model_name, + "gemini-flash-latest", + ) + self.assertEqual( + AIProvider.objects.get(pk=explicit_alias.pk).model_name, + "gemini-pro-latest", + ) diff --git a/apps/workflow/tests/test_notebook_lm_link_api.py b/apps/workflow/tests/test_notebook_lm_link_api.py new file mode 100644 index 000000000..1aa6b81c7 --- /dev/null +++ b/apps/workflow/tests/test_notebook_lm_link_api.py @@ -0,0 +1,86 @@ +"""Tests for /api/workflow/notebook-lm-links/ — menu filtering + CRUD permissions.""" + +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from apps.accounts.models import Staff +from apps.workflow.enums import NotebookLmRestriction +from apps.workflow.models import NotebookLmLink + +LIST_URL = "/api/workflow/notebook-lm-links/" +MENU_URL = "/api/workflow/notebook-lm-links/menu/" + + +def _staff(email: str, *, office: bool = False, superuser: bool = False) -> Staff: + return Staff.objects.create_user( + email=email, + password="x", + first_name="Test", + last_name="User", + is_office_staff=office, + is_superuser=superuser, + ) + + +def _link( + name: str, + *, + enabled: bool = True, + restriction: str = NotebookLmRestriction.NONE, + order: int = 0, +) -> NotebookLmLink: + return NotebookLmLink.objects.create( + name=name, + url=f"https://nb.test/{name}", + enabled=enabled, + restriction=restriction, + order=order, + ) + + +class NotebookLmMenuTests(APITestCase): + def setUp(self) -> None: + _link("Training", order=1) + _link("HS", order=2) + _link("Admin", restriction=NotebookLmRestriction.SUPERUSER, order=3) + _link("Disabled", enabled=False, order=0) + + def test_menu_hides_restricted_and_disabled_for_regular_staff(self) -> None: + client = APIClient() + client.force_authenticate(_staff("worker@example.test")) + resp = client.get(MENU_URL) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + names = [row["name"] for row in resp.json()] + self.assertEqual(names, ["Training", "HS"]) + + def test_menu_includes_restricted_for_superuser(self) -> None: + client = APIClient() + client.force_authenticate(_staff("root@example.test", superuser=True)) + resp = client.get(MENU_URL) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + names = [row["name"] for row in resp.json()] + self.assertEqual(names, ["Training", "HS", "Admin"]) + + def test_menu_requires_authentication(self) -> None: + resp = self.client.get(MENU_URL) + self.assertIn(resp.status_code, (401, 403)) + + +class NotebookLmCrudPermissionTests(APITestCase): + def test_non_office_staff_cannot_create(self) -> None: + client = APIClient() + client.force_authenticate(_staff("worker@example.test")) + resp = client.post( + LIST_URL, {"name": "X", "url": "https://nb.test/x"}, format="json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(NotebookLmLink.objects.filter(name="X").exists()) + + def test_office_staff_can_create(self) -> None: + client = APIClient() + client.force_authenticate(_staff("office@example.test", office=True)) + resp = client.post( + LIST_URL, {"name": "X", "url": "https://nb.test/x"}, format="json" + ) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + self.assertTrue(NotebookLmLink.objects.filter(name="X").exists()) diff --git a/apps/workflow/urls.py b/apps/workflow/urls.py index 23d5783f1..6b123231f 100644 --- a/apps/workflow/urls.py +++ b/apps/workflow/urls.py @@ -28,6 +28,7 @@ from apps.workflow.views.company_defaults_logo_api import CompanyDefaultsLogoAPIView from apps.workflow.views.company_defaults_schema_api import CompanyDefaultsSchemaAPIView from apps.workflow.views.data_versions_view import DataVersionsAPIView +from apps.workflow.views.notebook_lm_link_viewset import NotebookLmLinkViewSet from apps.workflow.views.search_telemetry_view import SearchTelemetryClickAPIView from apps.workflow.views.session_replay_view import ( SessionReplayChunkCreateView, @@ -46,6 +47,7 @@ # --------------------------------------------------------------------------- router = DefaultRouter() router.register("ai-providers", AIProviderViewSet, basename="ai-provider") +router.register("notebook-lm-links", NotebookLmLinkViewSet, basename="notebook-lm-link") router.register("app-errors", AppErrorViewSet, basename="app-error") router.register("xero-pay-items", XeroPayItemViewSet, basename="xero-pay-item") router.register("xero-apps", XeroAppViewSet, basename="xero-app") diff --git a/apps/workflow/views/notebook_lm_link_viewset.py b/apps/workflow/views/notebook_lm_link_viewset.py new file mode 100644 index 000000000..6e8b69008 --- /dev/null +++ b/apps/workflow/views/notebook_lm_link_viewset.py @@ -0,0 +1,40 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import permissions, viewsets +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from apps.accounts.models import Staff +from apps.job.permissions import IsOfficeStaff +from apps.workflow.enums import NotebookLmRestriction +from apps.workflow.models import NotebookLmLink +from apps.workflow.serializers import NotebookLmLinkSerializer + + +class NotebookLmLinkViewSet(viewsets.ModelViewSet[NotebookLmLink]): + """CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + """ + + permission_classes = [permissions.IsAuthenticated, IsOfficeStaff] + queryset = NotebookLmLink.objects.all() + serializer_class = NotebookLmLinkSerializer + + @extend_schema(responses={200: NotebookLmLinkSerializer(many=True)}) + @action( + detail=False, + methods=["get"], + permission_classes=[permissions.IsAuthenticated], + ) + def menu(self, request: Request) -> Response: + user = request.user + include_restricted = isinstance(user, Staff) and user.is_superuser + links = NotebookLmLink.objects.filter(enabled=True) + if not include_restricted: + links = links.exclude(restriction=NotebookLmRestriction.SUPERUSER) + serializer = self.get_serializer(links, many=True) + return Response(serializer.data) diff --git a/docs/client_onboarding.md b/docs/client_onboarding.md index f1797c252..dd68fc37f 100644 --- a/docs/client_onboarding.md +++ b/docs/client_onboarding.md @@ -186,7 +186,7 @@ For each provider the client wants to use: - [ ] **Provider name** (friendly label) - [ ] **Provider type** (Gemini / Claude / OpenAI / Mistral) -- [ ] **Model name** (e.g. `gemini-2.5-flash-lite-preview-06-17`) +- [ ] **Model name** (e.g. `gemini-flash-latest` for automatic Gemini upgrades) - [ ] **API key** - [ ] Whether it should be the **default** provider diff --git a/docs/urls/accounts.md b/docs/urls/accounts.md index 41e94134c..c5a638b42 100644 --- a/docs/urls/accounts.md +++ b/docs/urls/accounts.md @@ -21,7 +21,7 @@ | URL Pattern | View | Name | Description | |-------------|------|------|-------------| | `/staff/` | `staff_api.StaffListCreateAPIView` | `accounts:api_staff_list_create` | API endpoint for listing and creating staff members. | -| `/staff//` | `staff_api.StaffRetrieveUpdateDestroyAPIView` | `accounts:api_staff_detail` | API endpoint for retrieving, updating, and deleting individual staff members. | +| `/staff//` | `staff_api.StaffRetrieveUpdateAPIView` | `accounts:api_staff_detail` | API endpoint for retrieving and updating individual staff members. | | `/staff/all/` | `staff_views.StaffListAPIView` | `accounts:api_staff_all_list` | API endpoint for retrieving list of staff members for Kanban board. | | `/staff/rates//` | `staff_views.get_staff_rates` | `accounts:get_staff_rates` | Retrieve wage rates for a specific staff member. | diff --git a/frontend/README.md b/frontend/README.md index 0bbd284c0..bed4b2d7a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -40,23 +40,6 @@ Source files live in the `src/` directory: - **types/** – TypeScript interfaces - **views/** – Page‑level Vue components -## Training Manual - -The staff training manual is built with VitePress and served at `/manual/` in production. - -```bash -npm run manual:dev # Dev server on port 5174 (hot-reload) -npm run manual:build # Production build to dist-manual/ -npm run manual:screenshots # Capture screenshots (needs running app + .env credentials) -``` - -Markdown source lives in `manual/`. Edit pages there and preview with `manual:dev`. - -PDF export is not wired up: the only working exporter (`vitepress-export-pdf`) is -unmaintained and its stale `vitepress` peer range blocks upgrading vitepress past -1.x, which is required to clear transitive `vite`/`esbuild` CVEs. The web build -is the supported delivery; revisit if an actively-maintained exporter appears. - ## Additional Documentation See `docs/overview.md` for a newcomer‑oriented explanation of the codebase. diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 55217bc35..4cff816ce 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -26,8 +26,6 @@ export default defineConfigWithVueTs( globalIgnores([ '**/dist/**', '**/dist-ssr/**', - '**/dist-manual/**', - 'manual/.vitepress/cache/**', '**/coverage/**', '**/scripts/**', '**/playwright-report/**', diff --git a/frontend/manual/.vitepress/config.ts b/frontend/manual/.vitepress/config.ts deleted file mode 100644 index efd1c865c..000000000 --- a/frontend/manual/.vitepress/config.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { defineConfig } from 'vitepress' - -export default defineConfig({ - title: 'DocketWorks Training Manual', - description: 'How to do your job - a cookbook for staff', - - // Served at /manual/ alongside the main app - base: '/manual/', - - // Output directory (relative to manual/ folder) - outDir: '../dist-manual', - - themeConfig: { - nav: [{ text: 'Home', link: '/' }], - - sidebar: [ - { - text: 'Customer Contact', - items: [{ text: 'New Customer Call', link: '/enquiries/new-customer-call' }], - }, - { - text: 'Jobs', - items: [ - { text: 'Understanding Job Finances', link: '/jobs/understanding-job-finances' }, - { text: 'Attach Files to a Job', link: '/jobs/attach-files' }, - ], - }, - { - text: 'Quoting', - items: [ - { text: 'Assess & Price a Job', link: '/quoting/assess-and-price' }, - { text: 'Send a Quote', link: '/quoting/send-quote' }, - ], - }, - { - text: 'Scheduling', - items: [{ text: 'Schedule a Job', link: '/scheduling/schedule-a-job' }], - }, - { - text: 'Fieldwork', - items: [{ text: 'Complete a Job On-Site', link: '/fieldwork/complete-a-job' }], - }, - { - text: 'Timesheets', - items: [{ text: 'End of Day Entry', link: '/timesheets/end-of-day-entry' }], - }, - { - text: 'Purchasing', - items: [{ text: 'Create a Purchase Order', link: '/purchasing/create-purchase-order' }], - }, - { - text: 'Invoicing', - items: [{ text: 'Invoice a Job', link: '/invoicing/invoice-a-job' }], - }, - { - text: 'Weekly & Monthly Procedures', - items: [{ text: 'Weekly Checklist', link: '/end-of-week/weekly-checklist' }], - }, - { - text: 'Management & Admin', - items: [ - { text: 'Run Reports', link: '/management/run-reports' }, - { text: 'Run Payroll', link: '/admin/run-payroll' }, - { text: 'Manage Staff', link: '/admin/manage-staff' }, - ], - }, - ], - - search: { - provider: 'local', - }, - - outline: { - level: [2, 3], - }, - }, - - lastUpdated: true, -}) diff --git a/frontend/manual/admin/manage-staff.md b/frontend/manual/admin/manage-staff.md deleted file mode 100644 index e42ed69f3..000000000 --- a/frontend/manual/admin/manage-staff.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: Manage Staff ---- - -# Manage Staff - -> **When to use:** You need to add a new team member, update someone's details, or change their permissions. - -## What You'll Need - -- [ ] Superuser access (only superusers can manage staff) -- [ ] The new person's details (name, email, wage rate) - -## Steps - -### 1. Open Staff Management - -Navigate to **Admin > Staff** (you need to be a superuser to see this). - - - -You'll see a table listing all staff members with their name, role (Office Staff / SuperUser), last login, and date joined. - -### 2. Add a new staff member - -Click **New Staff** to open the staff form. It has three tabs: - -#### Personal Info - - - -- **First Name** and **Last Name** -- as you'd expect. -- **Preferred Name** -- what they go by day-to-day (optional). -- **Email** -- their login email. Must be unique. -- **Password** -- at least 8 characters. They can change it later. -- **Base Wage Rate** -- their hourly wage in NZD. This is used for job costing calculations. -- **Xero User ID** -- links them to their Xero profile for payroll (optional). -- **Profile Icon** -- click the avatar to upload a photo. - -#### Working Hours - - - -Set the scheduled hours for each day of the week (Monday through Sunday). Enter in quarter-hour increments (0.25, 0.5, etc.). These are used in the timesheet summary to show whether a full day has been entered. - -#### Permissions - -- **Is Office Staff** -- tick this for office-based staff. It controls which navbar items they see (office staff see everything; non-office staff see a simplified view focused on their own time entry). -- **Is SuperUser** -- tick this for people who need admin access (staff management, system settings, etc.). - -### 3. Edit an existing staff member - -Click the **Edit** button next to any staff member in the table. The same form opens, pre-filled with their current details. You can change anything except their email. - -### 4. Remove a staff member - -Click the **Delete** button next to their name. You'll be asked to confirm before anything happens. - -## What Happens Next - -- New staff members can log in immediately with their email and password -- Their wage rate feeds into job costing whenever they enter time -- Their scheduled hours appear in the timesheet summary for checking daily totals -- If linked to Xero, their payroll data can be reconciled using the [Payroll report](/admin/run-payroll) - -## Tips - -::: tip -Set up working hours accurately -- they're used to check whether a full day of time has been entered. If someone works 7.5 hours and the system expects 8, it'll flag every day as incomplete. -::: - -::: warning -Be careful with the SuperUser permission. SuperUsers can see everything and manage all settings. Only give this to people who genuinely need it. -::: diff --git a/frontend/manual/admin/run-payroll.md b/frontend/manual/admin/run-payroll.md deleted file mode 100644 index 23cefb3cd..000000000 --- a/frontend/manual/admin/run-payroll.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Run Payroll ---- - -# Run Payroll - -> **When to use:** You need to check that what Xero paid in payroll matches what DocketWorks calculated from timesheets. - -## What You'll Need - -- [ ] Access to the Payroll Reconciliation report -- [ ] Payroll already run in Xero for the period you're checking - -## Steps - -### 1. Open the Payroll Reconciliation report - -Navigate to **Reports > Reconciliation > Payroll (Xero)**. - - - -### 2. Set the date range - -Pick your start and end dates, or use one of the quick presets: - -- **This FY** -- current financial year -- **Last FY** -- previous financial year -- **Last 30 Weeks** -- a rolling window - -The dates snap to week boundaries automatically -- you don't need to worry about picking exact Monday-to-Sunday ranges. - -### 3. Read the summary cards - -At the top you'll see three cards: - -- **Xero Total** -- What Xero says was paid in gross payroll -- **DW Total** -- What DocketWorks calculated from timesheets and wage rates -- **Difference** -- The gap between the two, shown in dollars and as a percentage - -If the difference is small (a few dollars), everything's fine. If it's significant, something needs investigating. - -### 4. Use the heatmap to find problems - - - -The heatmap grid shows every week (rows) by every staff member (columns). Each cell is colour-coded: - -- **Green** -- Difference is less than $1. Xero and DWagree. -- **Blue shades** -- Xero paid more than DWcalculated (overpayment or a Xero adjustment). -- **Red shades** -- Xero paid less than DWcalculated (underpayment or missing hours in Xero). Darker red means a bigger gap. - -### 5. Drill into the details - -Hover over any cell to see the full breakdown: - -- **Xero**: Hours worked and gross amount paid -- **DW**: Hours entered and calculated cost -- **Gap**: The dollar difference -- **Hours impact**: How much of the gap comes from different hours -- **Rate impact**: How much comes from different wage rates - -This tells you whether the problem is missing hours (someone didn't enter all their time) or a rate mismatch (the wage rate in DWdoesn't match Xero). - -### 6. Export if needed - -Click **Export CSV** to download the data for further analysis in Excel, or to share with your accountant. - -## What Happens Next - -- If everything matches, you're done -- payroll is reconciled for that period -- If there are discrepancies, investigate the red/blue cells: - - **Missing hours**: Check timesheets for that staff member in that week - - **Rate mismatch**: Compare the wage rate in [Staff Management](/admin/manage-staff) with Xero - - **Xero adjustments**: Check if Xero has manual adjustments (leave, bonuses, etc.) that DWwouldn't know about - -## Tips - -::: tip -Run this report weekly right after payroll. Catching a discrepancy the same week is easy to fix. Finding it three months later is a headache. -::: - -::: warning -The report compares gross figures. If Xero has manual adjustments (sick leave, bonuses, deductions), they'll show as differences here. That's expected -- just make sure you can account for them. -::: diff --git a/frontend/manual/end-of-week/weekly-checklist.md b/frontend/manual/end-of-week/weekly-checklist.md deleted file mode 100644 index 6aeaf15e8..000000000 --- a/frontend/manual/end-of-week/weekly-checklist.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Weekly Checklist ---- - -# Weekly Checklist - -> **When to use:** End of the week admin procedures -- making sure nothing's fallen through the cracks. - -## What You'll Need - -- [ ] Access to reports and the Kanban board -- [ ] 30-60 minutes of uninterrupted time - -## Weekly Tasks - -### Review Outstanding Quotes - -Check for any quotes that have been sent but not responded to. Open the Kanban board and look at jobs sitting in the "Quoted" column. If anything's been there more than a week, follow up with the customer. - - - -### Check Unbilled Work - -Look for jobs that are complete but haven't been invoiced yet. These are jobs sitting in the "Complete" column on the Kanban board, or you can use the Job Aging report under Reports > Management to find them. - -Every week a completed job sits uninvoiced is a week you're not getting paid for work you've already done. - -### Timesheet Review - -Go through each staff member's timesheets for the week using the Daily Overview. Check that: - -- Everyone has entered a full day's hours for each day they worked -- The summary shows hours that match their scheduled hours -- There aren't unusual patterns (lots of non-billable time, missing days, etc.) - - - -If someone's hours don't add up, check with them before the week is out. It's much easier to fix on Friday than to reconstruct the following week. - -### Month End (Monthly) - -At the end of each month, run the month-end process under Admin. This is mostly for shop jobs -- it resets the hours each month so you start fresh. - - - -### Follow-up Actions - -- Chase any overdue customer payments (check in Xero) -- Review the [KPI Report](/management/run-reports) for the week -- are we tracking to target? -- Update job statuses on the Kanban board for anything that's moved along during the week -- Flag any jobs that are going over budget so they can be discussed - -## Tips - -::: tip -Make this a recurring calendar event every Friday afternoon. It takes less time than you think and prevents small problems from becoming big ones. -::: - -::: warning -Don't skip the timesheet review. Missing or inaccurate timesheets mean inaccurate job costing, which means you don't know if you're making or losing money. -::: diff --git a/frontend/manual/enquiries/new-customer-call.md b/frontend/manual/enquiries/new-customer-call.md deleted file mode 100644 index 596ca5cbd..000000000 --- a/frontend/manual/enquiries/new-customer-call.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: New Customer Call ---- - -# New Customer Call - -> **When to use:** A new or existing customer calls asking about work they need done. - -## What You'll Need - -- [ ] Customer name and contact details -- [ ] Site address (if different from customer address) -- [ ] Brief description of what they need - -## Steps - -### 1. Check if they're an existing customer - - - -Search for the customer by name or phone number. If they exist, you'll see their history. - -If they're new, you'll create a record as part of the next step. - -### 2. Create a new job - - - -Click **Create Job** in the navbar. Fill in the basic details: - -- **Job Number** -- This is auto-generated, just like our old paper job numbers. -- **Job Name** -- An internal nickname for the job. This is handy for searching later, so make it something memorable (e.g. "Smith fence repair" rather than "Job for Mr Smith"). -- **Client** -- This must match the entry in Xero. Start typing and it'll autocomplete. If the client doesn't exist, you can create one from here. -- **Job Description** -- This gets printed on invoices and quotes, so write it for the customer's eyes. Keep it professional and clear. - -### 3. Record the key details - - - -The other fields on the job form: - -- **Contact Name** and **Phone Number** -- Who to call about this job. -- **Order Number** -- The client's reference number, if they have one. -- **Job Notes** -- Internal notes that only we see. Use this for anything relevant -- site access codes, special instructions, who you spoke to on the phone. - -Don't worry about filling in everything right now. The critical fields are the client, job name, and a description. Everything else can be added later as you learn more. - -### 4. Set next steps - -Once you've saved the job, think about what happens next. Does someone need to go out for a site visit? Can you quote over the phone? Is it a straightforward T&M job that just needs scheduling? - -If you know, update the job status on the Kanban board to reflect where it's at. If not, leave it in the first column and it'll get picked up in the normal workflow. - -## What Happens Next - -- Job appears on the Kanban board where everyone can see it -- Someone picks it up to assess and price -- see [Assess & Price a Job](/quoting/assess-and-price) -- The job number is the reference for everything from here on out - -## Tips - -::: tip -Always confirm the site address -- it's often different from the customer's postal or billing address. Getting this wrong wastes everyone's time. -::: - -::: warning -Don't promise a quote timeframe without checking the schedule first. Better to say "we'll get back to you" than to commit to something you can't deliver. -::: diff --git a/frontend/manual/fieldwork/complete-a-job.md b/frontend/manual/fieldwork/complete-a-job.md deleted file mode 100644 index a271654cd..000000000 --- a/frontend/manual/fieldwork/complete-a-job.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Complete a Job On-Site ---- - -# Complete a Job On-Site - -> **When to use:** You're on-site, the work is done, and you need to mark the job as complete. - -## What You'll Need - -- [ ] Access to DocketWorks (phone or tablet is fine) -- [ ] Any required sign-off or photos from the customer - -## Steps - -### 1. Record your time - -Make sure all your hours for this job are entered in the timesheets. If you haven't done it during the day, do it now while the details are fresh -- see [End of Day Entry](/timesheets/end-of-day-entry). - -### 2. Note any materials used - -If you used materials on-site that haven't been recorded yet, make a note. These need to be entered into the Reality section of the job so the costs are accurate. - -### 3. Take photos (if needed) - -If there's anything worth documenting -- the finished work, any issues found, before/after shots -- attach them to the job. Files added to a job automatically sync to Dropbox as well. See [Attach Files to a Job](/jobs/attach-files). - -### 4. Update the job status - -Move the job to "Complete" on the Kanban board (or ask office staff to do it if you don't have access). This signals to the office that the work is done and the job is ready for invoicing. - -## What Happens Next - -- Office staff see the job in the "Complete" column on the Kanban board -- They'll review the timesheets and materials, then [invoice the job](/invoicing/invoice-a-job) -- The job's actual costs feed into the KPI reports automatically - -## Tips - -::: tip -Take photos of finished work, especially for larger jobs. They're useful for resolving disputes and for showing prospective customers what you've done. -::: - -::: warning -Don't mark a job as complete until all your time is entered. Once it moves to invoicing, missing hours mean you either don't get paid for them or someone has to go back and fix it. -::: diff --git a/frontend/manual/index.md b/frontend/manual/index.md deleted file mode 100644 index 17e6015d6..000000000 --- a/frontend/manual/index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Home ---- - -# DocketWorks Training Manual - -Welcome to the training manual. This is a cookbook for doing your job - not a software manual. - -Each page covers a **business task** you need to complete, with step-by-step instructions. - -## Where to Start - -Pick the task you need to do: - -### Customer & Sales - -- [New Customer Call](/enquiries/new-customer-call) - When a customer contacts you -- [Assess & Price a Job](/quoting/assess-and-price) - Working out what to charge -- [Send a Quote](/quoting/send-quote) - Getting the quote to the customer - -### Understanding Jobs - -- [Understanding Job Finances](/jobs/understanding-job-finances) - How estimates, quotes, and reality fit together -- [Attach Files to a Job](/jobs/attach-files) - Drawings, photos, and documents - -### Getting Work Done - -- [Schedule a Job](/scheduling/schedule-a-job) - Assigning work to staff -- [Complete a Job On-Site](/fieldwork/complete-a-job) - What field staff do -- [End of Day Entry](/timesheets/end-of-day-entry) - Recording your time - -### Purchasing - -- [Create a Purchase Order](/purchasing/create-purchase-order) - Ordering materials from suppliers - -### Billing & Admin - -- [Invoice a Job](/invoicing/invoice-a-job) - Billing the customer -- [Weekly Checklist](/end-of-week/weekly-checklist) - End of week procedures -- [Run Reports](/management/run-reports) - Getting insights -- [Run Payroll](/admin/run-payroll) - Reconciling timesheets with Xero payroll -- [Manage Staff](/admin/manage-staff) - Adding and editing team members - ---- - -::: tip For Authors -To add or edit recipes, see the [Writing Guide](#writing-guide) below. -::: - -## Writing Guide - -Each recipe follows this structure: - -1. **When to use** - One line describing the situation -2. **What you'll need** - Prerequisites checklist -3. **Steps** - Numbered actions with explanations -4. **What happens next** - Outcomes and follow-ups -5. **Tips** - Business knowledge and warnings - -Screenshots are auto-generated. Mark where you need one with: - -```markdown - -``` diff --git a/frontend/manual/invoicing/invoice-a-job.md b/frontend/manual/invoicing/invoice-a-job.md deleted file mode 100644 index 9f2491f84..000000000 --- a/frontend/manual/invoicing/invoice-a-job.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Invoice a Job ---- - -# Invoice a Job - -> **When to use:** The work is done, time and materials are recorded, and it's time to bill the customer. - -## What You'll Need - -- [ ] A completed job with all timesheet entries recorded -- [ ] Materials entered in the Reality section (if applicable) -- [ ] Customer billing details in the system - -## Steps - -### 1. Check the Reality section - - - -Before you invoice, open the job and look at the Reality section. This shows the real cost and revenue of the job -- what actually happened versus what you estimated. - -The timesheets section of the app automatically populates the time entries here. If staff have been entering their time correctly, the labour lines should already be filled in. Materials you'll need to enter directly into the Reality section for now. - -### 2. Compare estimate to reality - - - -The system keeps track of the totals for you. Check the Revenue vs Costs summary at the bottom of the job. You'll see columns for Estimate, Quote, and Reality side by side. - -Key things to look for: - -- **Revenue higher than cost** = the job made a profit -- **Cost higher than revenue** = the job lost money -- investigate before invoicing -- **Big gap between estimate and reality** = something went differently than planned - -### 3. Create the invoice - - - -Once you're satisfied that the numbers are right, create the invoice. This sends the billing information through to Xero where the actual invoice is generated and sent to the customer. - -### 4. Verify in Xero - -The invoice should appear in Xero shortly after creation. Check that the amounts match and that the customer details are correct. - -## What Happens Next - -- The invoice is created in Xero and sent to the customer -- The job status updates to reflect that it's been invoiced -- Payment tracking happens in Xero from this point -- The job's profitability is now final and will show in the KPI reports - -## Tips - -::: tip -Always check the Reality section before invoicing. If timesheets are missing or materials haven't been recorded, your invoice won't reflect the actual work done -- and you'll either undercharge the customer or have inaccurate job costing. -::: - -::: warning -If the Reality section shows the job lost money, don't just invoice and move on. Flag it so the team can learn from it. Was the estimate too low? Did the scope change? Understanding why helps prevent the same thing happening next time. -::: diff --git a/frontend/manual/jobs/attach-files.md b/frontend/manual/jobs/attach-files.md deleted file mode 100644 index 8dbab8ce8..000000000 --- a/frontend/manual/jobs/attach-files.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Attach Files to a Job ---- - -# Attach Files to a Job - -> **When to use:** You need to add drawings, photos, documents, or any other files to a job. - -## What You'll Need - -- [ ] The file(s) you want to attach (drawings, photos, PDFs, etc.) -- [ ] The job open in DocketWorks - -## Steps - -### 1. Open the job and go to the Files section - -Navigate to the job and find the Attached Files area. - - - -### 2. Upload your files - -Drag files into the upload area, or click to browse and select them. You can upload multiple files at once. - -### 3. Verify the upload - -Your files will appear in the attachments list. They're available immediately to anyone viewing the job. - -## Dropbox Sync - -Any files you add to a job will immediately turn up in Dropbox as well. This is particularly helpful for the laser cutter -- upload a drawing to the job and it's available on the workshop machine straight away. - -The same applies in reverse. Any files you add to the job's Dropbox folder will appear in DocketWorks. So if it's easier to drop something into Dropbox from your phone or desktop, that works too. - -## What Happens Next - -- Files are attached to the job and visible to all staff -- Files sync to Dropbox automatically (both directions) -- They stay with the job as a permanent record -- useful for reference on repeat customers or warranty queries - -## Tips - -::: tip -Always attach drawings to the job rather than emailing them around. That way they're archived and anyone can find them later without hunting through their inbox. -::: - -::: tip -Take before/after photos on bigger jobs and attach them. They're invaluable for resolving disputes and for showing prospective customers your work. -::: diff --git a/frontend/manual/jobs/understanding-job-finances.md b/frontend/manual/jobs/understanding-job-finances.md deleted file mode 100644 index 058d0447b..000000000 --- a/frontend/manual/jobs/understanding-job-finances.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Understanding Job Finances ---- - -# Understanding Job Finances - -> **When to use:** You want to understand how the money works on a job -- what the three financial stages mean and how they fit together. - -This isn't a step-by-step task. It's background knowledge that makes everything else make sense. - -## The Three-Part Structure - -From a commercial perspective, every job is divided into three parts: - -- **Time** -- Labour hours and wage/charge rates -- **Materials** -- Physical goods, supplies, stock items -- **Adjustments** -- Everything else: call-out fees, travel, discounts, write-offs - -You'll see this three-part breakdown everywhere in the system -- estimates, quotes, reality, and reports. - -## The Three Financial Stages - -Each job goes through three financial stages. Think of them as three different views of the same job: - -### Estimate (What we think it'll cost) - - - -The estimate is your internal "finger in the air" -- what you think the job will cost us and what we should charge. It has time, materials, and adjustments sections, each with as many lines as you need. - -Every job should have an estimate. Even a rough one is better than none. - -### Quote (What we tell the customer) - - - -The quote is the customer-facing version. Not every job needs a quote -- T&M (time and materials) jobs where you bill as you go don't need one. But if the customer wants a price upfront, this is where it lives. - -The **Copy Estimate to Quote** button gives you a starting point. From there you can adjust -- add contingency, simplify the breakdown, reduce the price to win the work. - -Remember: all jobs have an estimate, while only some have a quote. - -### Reality (What actually happened) - - - -The Reality section shows the real cost and revenue of the job. If revenue is higher than cost, the job made a profit. - -- **Time entries** are populated automatically from timesheets. When staff enter time against a job, it shows up here. -- **Materials** need to be entered directly into the Reality section for now. -- **Adjustments** are added manually as needed. - -## The Revenue vs Costs Summary - - - -At the bottom of the job you'll see summary tables that put it all together: - -**Revenue** shows what the customer is being charged: - -| Category | Estimate | Quote | Reality | -| ---------------------- | -------- | ------ | ------- | -| Total Time | $X | $X | $X | -| Total Materials | $X | $X | $X | -| Total Adjustments | $X | $X | $X | -| **Total Project Cost** | **$X** | **$X** | **$X** | - -**Costs** shows what it costs us: - -| Category | Estimate | Quote | Reality | -| ---------------------- | -------- | ------ | ------- | -| Total Time | $X | $X | $X | -| Total Materials | $X | $X | $X | -| Total Adjustments | $X | $X | $X | -| **Total Project Cost** | **$X** | **$X** | **$X** | - -The difference between Revenue and Costs is your profit. You can see at a glance whether the job is on track, over budget, or better than expected. - -## How It All Connects - -1. You [create a job](/enquiries/new-customer-call) and fill in the **Estimate** -2. If the customer needs a price, you populate the **Quote** and [send it](/quoting/send-quote) -3. Staff do the work and enter time -- the **Reality** section fills up automatically -4. When the job is done, you compare Reality to Estimate to see how you went -5. You [invoice the job](/invoicing/invoice-a-job) and it feeds into the [KPI reports](/management/run-reports) - -## Tips - -::: tip -Get in the habit of checking the Revenue vs Costs summary before invoicing. A quick glance tells you if the job made money or not -- and if not, why not. -::: - -::: warning -Don't confuse the Estimate with the Quote. The Estimate is for us (what we think it costs). The Quote is for the customer (what we're charging them). They can be different -- and often should be. -::: diff --git a/frontend/manual/management/run-reports.md b/frontend/manual/management/run-reports.md deleted file mode 100644 index 6e355fe43..000000000 --- a/frontend/manual/management/run-reports.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: Run Reports ---- - -# Run Reports - -> **When to use:** You want to know how the business is tracking -- profitability, job progress, staff output, or anything else that needs a number behind it. - -## What You'll Need - -- [ ] Access to DocketWorks (you need to be logged in) -- [ ] A rough idea of the date range you're interested in - -## Available Reports - -All reports live under the **Reports** menu in the navbar. They're grouped into categories: - -| Category | Reports | What they tell you | -| ------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------- | -| **CRM** | Clients | Customer list and contact details | -| **Management** | Job Aging, Job Movement, Job Profitability, KPI Reports, Sales Forecast, Staff Performance | How the business is performing day-to-day | -| **Reconciliation** | Payroll (Xero), Profit & Loss (Xero) | Whether our books line up with Xero | -| **Data Quality** | Archived Jobs Validation | Finds problems in archived job data | - -Most of the time you'll be living in the **Management** reports, especially KPI Reports and Job Profitability. - -## Steps - -### 1. Open a report - -Click **Reports** in the top navbar, pick a category, then pick the report you want. - - - -### 2. Set your date range - -Most reports let you pick a month and year. Some have a **Today** button to jump back to the current period. - - - -### 3. Read the data - -Each report is laid out differently, but they all load automatically once you pick your dates. No need to click a "Run" button -- just change the month and the data refreshes. - -### 4. Export (if needed) - -Reports that support it have an **Export** button in the top-right corner. This gives you a download you can open in Excel or share with your accountant. - -## The KPI Report (In Depth) - -The KPI report is the one you'll probably use the most. It tells you approximately how much profit the business made in a day or a month. - -This is based on **when the time or parts are added to the job**, not on when the job is invoiced. If someone adds four hours on a job today, it counts for today -- not whatever day the job happens to be invoiced. - -### Summary cards - -At the top of the page you'll see four cards: - - - -- **Labour** -- Billable hours billed to clients, total wages paid, and the average daily billable hours as a percentage. This is the big one. If the team billed 38 hours against a 45-hour target, it'll show as amber. Hit the target and it goes green. -- **Materials** -- Material profit, revenue, cost, and margin percentage. -- **Adjustments** -- Same breakdown as materials but for adjustments (discounts, write-offs, extras). -- **Profit** -- The bottom line: net profit, total revenue, gross profit, and net margin percentage. - -You can click any card to see a more detailed breakdown. - -### Calendar heatmap - -Below the cards is a calendar view of the month. Each day is colour-coded: - -- **Green** = good day (hit targets) -- **Amber** = okay, but below target -- **Red** = bad day (lost money or well below target) - - - -Each day shows the hours worked and the profit or loss for that day. You can see at a glance which days went well and which didn't. - -### Daily detail (click a day) - -Click on any day in the calendar and a detail panel pops up showing: - - - -- **Revenue table** -- Labour Revenue, Material Revenue, Adjustment Revenue, Total Revenue -- **Cost table** -- Labour Cost, Material Cost, Adjustment Cost, Total Cost -- **Gross Profit Breakdown** -- Labour Profit, Material Profit, Adjustment Profit, Total Gross Profit -- **Profit by Job** -- A table listing every job that had activity that day (Job #, Labour, Materials, Adjustments, Total). Profitable jobs show in green. Jobs that lost money show in red/pink. - -The negative numbers you'll sometimes see for time on jobs are internal jobs -- things like shop maintenance, training, or admin. Those are expected. - -## What Happens Next - -- Reports update in real time as staff enter time, materials, and adjustments throughout the day -- Use the data to spot problems early -- a run of red days means something needs attention -- Share exports with your accountant or use them in team meetings -- The Reconciliation reports (Payroll and P&L) are for checking that DocketWorks lines up with what's in Xero - -## Tips - -::: tip -The KPI report is most useful at the end of each day. Check it before you leave to see how the team went. A quick glance at the calendar heatmap tells you everything you need to know. -::: - -::: tip -If a day is showing red, click into it and look at the Profit by Job table. The red rows will tell you exactly which jobs lost money and why. -::: - -::: warning -The KPI numbers are based on when work is recorded, not when it's invoiced. Don't compare KPI figures directly to your Xero invoicing for the same month -- they measure different things. Use the Reconciliation reports for that. -::: diff --git a/frontend/manual/purchasing/create-purchase-order.md b/frontend/manual/purchasing/create-purchase-order.md deleted file mode 100644 index 2cfe0f969..000000000 --- a/frontend/manual/purchasing/create-purchase-order.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Create a Purchase Order ---- - -# Create a Purchase Order - -> **When to use:** You need to order materials or supplies from a supplier for a job. - -## What You'll Need - -- [ ] Supplier details (must be set up in the system) -- [ ] List of items to order (descriptions, quantities, costs) -- [ ] The job number to allocate the purchase against - -## Steps - -### 1. Create a new PO - -Navigate to **Purchases > Purchase Orders** in the navbar, then click **New Purchase Order**. - - - -Fill in the basic details: - -- **Supplier** -- Select from the dropdown. Must match an existing supplier. -- **Reference** -- Your internal reference or the supplier's quote number. -- **Expected Delivery Date** -- When you expect the goods to arrive. -- **Pickup Address** -- If applicable, where the goods will be picked up from. - -Click **Save** to create the PO and open it for editing. - -### 2. Add line items - - - -In the line items table, add each item you're ordering: - -| Field | What it means | -| --------------- | ---------------------------------------- | -| **Item Code** | The supplier's product code | -| **Description** | What you're ordering | -| **Quantity** | How many | -| **Unit Cost** | Price per unit | -| **Price TBC** | Tick this if you don't know the cost yet | -| **Job** | Which job this item is for | - -You can assign different line items to different jobs on the same PO. - -### 3. Review and submit - -Check the totals and make sure everything looks right. When you're ready, change the status from **Draft** to **Submitted**. - - - -Once submitted, the supplier and line items are locked -- you can still update delivery dates and pickup addresses, but the core order details are fixed. - -### 4. Send to the supplier - -Use the **Email** button to send the PO to the supplier via email, or **Print** to generate a PDF you can send manually. - -If the supplier is set up in Xero, you can use the **Sync with Xero** button to push the PO through to Xero as well. - -### 5. Record deliveries - -As goods arrive, update the received quantities on each line item. The PO status will automatically move from "Submitted" to "Partially Received" and finally "Fully Received" as you record deliveries. - -## What Happens Next - -- The PO is tracked in the system with a clear status (Draft → Submitted → Partially Received → Fully Received) -- Costs are allocated against the job(s) specified on each line -- If synced to Xero, the PO appears there for accounting purposes -- Material costs flow through to the job's Reality section and into the KPI reports - -## Tips - -::: tip -Use the **Price TBC** checkbox when you know you need to order something but are waiting on a price. This lets you get the order started without holding things up. -::: - -::: tip -Add comments on the PO using the comments section at the bottom. This is handy for tracking conversations with the supplier about delivery changes, back-orders, etc. -::: - -::: warning -Once a PO is submitted, you can't change the supplier or line items. If you need to make changes, you'll need to delete the PO and create a new one. Double-check before submitting. -::: diff --git a/frontend/manual/quoting/assess-and-price.md b/frontend/manual/quoting/assess-and-price.md deleted file mode 100644 index 0c7de47c7..000000000 --- a/frontend/manual/quoting/assess-and-price.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: Assess & Price a Job ---- - -# Assess & Price a Job - -> **When to use:** You've got a job on the board and you need to work out what to charge before sending a quote. - -## What You'll Need - -- [ ] A job already created in the system (from the enquiry) -- [ ] A reasonable idea of what the work involves (site visit notes, photos, customer conversation) -- [ ] Access to your rate cards (wage rates, charge-out rates, material costs) - -## Steps - -### 1. Open the job and go to the Estimate section - -Navigate to the job and find the Estimate area. This is where you figure out whether the job is actually worth doing. - - - -From a commercial perspective, jobs are divided into three parts: **time**, **materials**, and **adjustments**. The estimate mirrors this structure -- you'll see a section for each. - -Please don't skip this step. Are we in a financial mess because we've been taking on jobs without knowing if they're profitable, or because we've been taking on jobs despite knowing upfront that we wouldn't make money on them? The estimate is your "finger in the air" -- it doesn't need to be perfect, but it does need to exist. - -### 2. Estimate the time - -The first grid covers labour. For each line you can enter: - -| Column | What it means | -| ----------------- | ---------------------------------------------------- | -| **Description** | What the work is (e.g. "Install signage", "4 folds") | -| **Items** | How many of this task | -| **Mins/Hours** | Time per item | -| **Total Minutes** | Calculated from Items x Mins/Hours | -| **Wage Rate** | What we pay the staff member per hour | -| **Charge Rate** | What we charge the customer per hour | - - - -You can have just one line for the whole job, or break it into multiple lines -- e.g. "4 folds" on one line and "final installation" on another. Pragmatically, decide based on how big the job is. Small jobs just get a single total time; bigger jobs benefit from a breakdown so you can see where the hours go. - -### 3. Estimate the materials - -The second grid covers materials. For each line: - -| Column | What it means | -| --------------- | ------------------------------------------------- | -| **Item Code** | Product or material code | -| **Description** | What it is | -| **Quantity** | How many / how much | -| **Cost Rate** | What we pay for it | -| **Retail Rate** | What we charge the customer | -| **Revenue** | Calculated from Quantity x Retail Rate | -| **Comments** | Anything worth noting (supplier, lead time, etc.) | - - - -Same principle as time -- one line for "materials" on a small job is fine. On a bigger job, break it out so you can see the margins on each item. - -### 4. Add any adjustments - -The third grid is for adjustments -- anything that doesn't fit neatly into time or materials. Think call-out fees, travel, discounts, or allowances. - -| Column | What it means | -| -------------------- | --------------------------------------- | -| **Description** | What the adjustment is for | -| **Cost Adjustment** | What it costs us | -| **Price Adjustment** | What we charge (or credit) the customer | -| **Revenue** | The net effect on revenue | -| **Comments** | Context for anyone reviewing later | - - - -### 5. Review the totals and check the margin - -Before you move on, look at the overall picture. Does the margin make sense? Does the total price feel right for the scope of work? If the numbers say we'll lose money, that's a conversation to have _now_ -- not after we've done the work. - - - -### 6. Copy the estimate to a quote - -Once you're happy with the estimate, hit the **Copy Estimate to Quote** button. This takes your workings and creates the customer-facing quote from them. - - - -## What Happens Next - -- The estimate is saved on the job as your internal cost/price workings. -- The quote is generated from the estimate, ready to review and send. -- Next step: [Send the Quote](/quoting/send-quote) to the customer. - -## Tips - -::: tip -The estimate is for _us_ -- it's our internal view of cost vs. price. The quote is what the customer sees. You can be as detailed as you like in the estimate without worrying about what the customer will think. -::: - -::: tip -For small jobs, don't overthink it. One line for time, one line for materials, done. Save the detailed breakdowns for jobs where the complexity warrants it. -::: - -::: warning -Don't skip the estimate just because the job seems straightforward. A two-minute estimate that shows you'll make 15% margin is infinitely better than no estimate and a nasty surprise at invoicing time. -::: - -::: warning -If the estimate shows we'll lose money or barely break even, flag it before sending the quote. It's much easier to adjust pricing now than to explain a loss after the job is done. -::: diff --git a/frontend/manual/quoting/send-quote.md b/frontend/manual/quoting/send-quote.md deleted file mode 100644 index 6a73c7ea7..000000000 --- a/frontend/manual/quoting/send-quote.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Send a Quote ---- - -# Send a Quote - -> **When to use:** The estimate is done, the quote section is filled in, and you need to get it in front of the customer. - -## What You'll Need - -- [ ] A completed estimate on the job (see [Assess & Price a Job](/quoting/assess-and-price)) -- [ ] The quote section populated (use the **Copy Estimate to Quote** button if you haven't already) -- [ ] Customer email address or other contact method - -## Steps - -### 1. Review the quote - - - -Open the job and go to the Quote section. The quote is what the customer will see, so check that it makes sense from their perspective. The structure is the same as the estimate -- time, materials, and adjustments -- but the numbers might differ. - -Remember: all jobs have an estimate, but only some have a quote. If it's a T&M (time and materials) job where you're billing as you go, you can skip the quote entirely and leave this section blank. - -### 2. Adjust if needed - -The **Copy Estimate to Quote** button gives you a starting point that matches your estimate exactly. From there you might want to: - -- Add some contingency (round up) -- Reduce the price to win the job -- Simplify the line items so the customer sees a cleaner breakdown - -The estimate stays unchanged as your internal record. The quote is the customer-facing version. - -### 3. Send the quote - - - -Use the **Quote Job** button to generate and send the quote to the customer. This creates a formatted quote document that goes out via email. - -## What Happens Next - -- The customer receives the quote by email -- The job status updates to reflect that a quote has been sent -- When the customer accepts, update the job status and move on to [scheduling](/scheduling/schedule-a-job) -- If they decline or want changes, adjust the quote and resend - -## Tips - -::: tip -If you used "Copy Estimate to Quote", double-check the numbers before sending. The estimate might have internal notes or line items that don't make sense to the customer. -::: - -::: warning -Don't send a quote without an estimate behind it. The estimate is how you know whether the job is profitable. Without it, you're guessing -- and guessing is how you lose money. -::: diff --git a/frontend/manual/scheduling/schedule-a-job.md b/frontend/manual/scheduling/schedule-a-job.md deleted file mode 100644 index a65fccf29..000000000 --- a/frontend/manual/scheduling/schedule-a-job.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Schedule a Job ---- - -# Schedule a Job - -> **When to use:** A quoted job has been accepted and needs to be assigned to staff for work. - -## What You'll Need - -- [ ] An accepted quote or approved job -- [ ] Knowledge of staff availability -- [ ] Customer's preferred timing (if any) - -## Steps - -### 1. Find the job on the Kanban board - - - -The Kanban board is your scheduling hub. Jobs that are ready to be scheduled will be sitting in the "Approved" column (or similar, depending on your workflow). - -### 2. Assign staff - - - -Use the staff panel at the top of the Kanban board to assign team members to the job. You can see who's already assigned to other jobs to avoid overloading anyone. - -### 3. Move the job to the right column - -Drag the job card to the appropriate status column (e.g. "In Progress" or "Scheduled") to reflect that it's been assigned and is ready to go. - -### 4. Confirm with the customer - -Let the customer know when to expect the team on-site. If there are specific access requirements or timing constraints, add them to the job notes so field staff can see them. - -## What Happens Next - -- Staff can see the job assigned to them on the Kanban board -- The job status is visible to everyone in the office -- Field staff do the work and record progress -- see [Complete a Job On-Site](/fieldwork/complete-a-job) - -## Tips - -::: tip -Check the job estimate before scheduling. If the estimate shows 2 days of work, make sure you've allowed for that in the schedule rather than squeezing it into a half-day gap. -::: - -::: warning -Don't move a job to "In Progress" until it's actually been assigned to someone. An unassigned job in the wrong column just creates confusion. -::: diff --git a/frontend/manual/timesheets/end-of-day-entry.md b/frontend/manual/timesheets/end-of-day-entry.md deleted file mode 100644 index a65dc1e1f..000000000 --- a/frontend/manual/timesheets/end-of-day-entry.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: End of Day Entry ---- - -# End of Day Entry - -> **When to use:** At the end of the day, you're entering time from staff time cards into the system. - -## What You'll Need - -- [ ] Time cards from staff (with job numbers, descriptions, and hours) -- [ ] Access to the Timesheets section - -## Steps - -### 1. Select the staff member and date - - - -Use the staff selector arrows at the top to pick whose time card you're entering. Set the date using the date picker, or hit **Today** to jump to the current date. - -The hours display in the header shows you at a glance how many hours have been entered versus the scheduled hours for that day. If the numbers don't match up, something's been missed. - -### 2. Choose the job number - - - -Start by entering the job number. This will populate the job name and client automatically -- you don't need to type those in. - -### 3. Enter the description and hours - - - -Write the description and the time exactly as the person has written it on their time card. The grid columns are: Job Number, Job Name, Client Name, Description, Hours, Rate, Wage, Bill, Billable, Date, and Approval Status. - -### 4. Handle non-billable time - -If it's not suitable to bill the client for the time -- say, fixing our own mistake -- untick the **Billable** checkbox. This way staff still get paid without overcharging the client or having to dump time onto shop jobs. - -You can add notes if you want, for example explaining why you unticked billable, but normally the description is enough. - -### 5. Check the daily summary - - - -The summary at the bottom tells you how many hours the staff member has entered for this day. Use it to quickly check whether a full day has been entered. If you see 4.0 out of 8.0, someone's time card is incomplete. - -It also shows you the split between billable and non-billable entries so you can spot anything unusual. - -### 6. Review from the overview - - - -From the overview you can see all open jobs with the time spent versus the estimate. This is the bar chart view -- blue bars are estimated hours, orange bars are actual hours. If the orange is creeping past the blue, that job is going over budget. - -### 7. Check a staff member's jobs - - - -From any staff member's view you can see the jobs they've put time on that day. Each job card shows the job number, name, client, status, estimated hours, and hours spent. - -You can click on the job number (shown in blue) to jump straight to that job. - -## What Happens Next - -- Time is recorded against the job and counts toward actual hours -- The hours feed into job costing -- estimated vs actual is tracked automatically -- Billable time will appear when it's time to invoice the client -- Non-billable time is still tracked for payroll but won't be charged to the client - -## Tips - -::: tip -Enter time at the end of each day while it's fresh. Trying to reconstruct a week's worth of time cards on Friday afternoon never goes well. -::: - -::: tip -If you see a staff member consistently under their scheduled hours in the summary, check with them before assuming the time card is wrong -- they may have had a shorter day. -::: - -::: warning -Don't just put non-billable time on a shop job to make it disappear. Use the billable checkbox on the actual job instead. That way you still have an accurate picture of how long the real job took -- you just aren't charging the client for it. -::: diff --git a/frontend/package.json b/frontend/package.json index cdeb15b9a..dde57f201 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,9 +25,6 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", "test:e2e:reset": "npx tsx tests/scripts/e2e-reset.ts", - "manual:dev": "vitepress dev manual --port 5174", - "manual:build": "vitepress build manual", - "manual:preview": "vitepress preview manual", "manual:screenshots": "npx tsx scripts/capture-screenshots.ts" }, "dependencies": { diff --git a/frontend/schema.yml b/frontend/schema.yml index e76be9589..ce828350d 100644 --- a/frontend/schema.yml +++ b/frontend/schema.yml @@ -664,11 +664,11 @@ paths: /api/accounts/staff/{id}/: get: operationId: accounts_staff_retrieve - description: API endpoint for retrieving, updating, and deleting individual - staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. - Includes comprehensive logging for update operations and handles multipart/form - data for file uploads. - summary: Retrieve, update, or delete staff member + description: API endpoint for retrieving and updating individual staff members. + Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging + for update operations and handles multipart/form data for file uploads. Staff + are not deleted; offboarding is done by setting date_left. + summary: Retrieve or update staff member parameters: - in: path name: id @@ -689,11 +689,11 @@ paths: description: '' put: operationId: accounts_staff_update - description: API endpoint for retrieving, updating, and deleting individual - staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. - Includes comprehensive logging for update operations and handles multipart/form - data for file uploads. - summary: Retrieve, update, or delete staff member + description: API endpoint for retrieving and updating individual staff members. + Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging + for update operations and handles multipart/form data for file uploads. Staff + are not deleted; offboarding is done by setting date_left. + summary: Retrieve or update staff member parameters: - in: path name: id @@ -723,11 +723,11 @@ paths: description: '' patch: operationId: accounts_staff_partial_update - description: API endpoint for retrieving, updating, and deleting individual - staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. - Includes comprehensive logging for update operations and handles multipart/form - data for file uploads. - summary: Retrieve, update, or delete staff member + description: API endpoint for retrieving and updating individual staff members. + Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging + for update operations and handles multipart/form data for file uploads. Staff + are not deleted; offboarding is done by setting date_left. + summary: Retrieve or update staff member parameters: - in: path name: id @@ -754,27 +754,6 @@ paths: schema: $ref: '#/components/schemas/Staff' description: '' - delete: - operationId: accounts_staff_destroy - description: API endpoint for retrieving, updating, and deleting individual - staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. - Includes comprehensive logging for update operations and handles multipart/form - data for file uploads. - summary: Retrieve, update, or delete staff member - parameters: - - in: path - name: id - schema: - type: string - format: uuid - required: true - tags: - - Staff Management - security: - - cookieAuth: [] - responses: - '204': - description: No response body /api/accounts/staff/all/: get: operationId: accounts_staff_all_list @@ -8988,6 +8967,212 @@ paths: schema: $ref: '#/components/schemas/AppError' description: '' + /api/workflow/notebook-lm-links/: + get: + operationId: workflow_notebook_lm_links_list + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + tags: + - workflow + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/NotebookLmLink' + description: '' + post: + operationId: workflow_notebook_lm_links_create + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + tags: + - workflow + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NotebookLmLinkRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/NotebookLmLinkRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/NotebookLmLinkRequest' + required: true + security: + - cookieAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/NotebookLmLink' + description: '' + /api/workflow/notebook-lm-links/{id}/: + get: + operationId: workflow_notebook_lm_links_retrieve + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this NotebookLM Link. + required: true + tags: + - workflow + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/NotebookLmLink' + description: '' + put: + operationId: workflow_notebook_lm_links_update + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this NotebookLM Link. + required: true + tags: + - workflow + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NotebookLmLinkRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/NotebookLmLinkRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/NotebookLmLinkRequest' + required: true + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/NotebookLmLink' + description: '' + patch: + operationId: workflow_notebook_lm_links_partial_update + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this NotebookLM Link. + required: true + tags: + - workflow + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedNotebookLmLinkRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedNotebookLmLinkRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedNotebookLmLinkRequest' + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/NotebookLmLink' + description: '' + delete: + operationId: workflow_notebook_lm_links_destroy + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this NotebookLM Link. + required: true + tags: + - workflow + security: + - cookieAuth: [] + responses: + '204': + description: No response body + /api/workflow/notebook-lm-links/menu/: + get: + operationId: workflow_notebook_lm_links_menu_list + description: |- + CRUD for NotebookLM training-menu links. + + Full CRUD is office-staff gated (the admin management surface). The extra + `menu` action is readable by any authenticated staff member and returns only + the enabled links they are allowed to see — the navbar reads that, so the + restriction filtering happens server-side. + tags: + - workflow + security: + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/NotebookLmLink' + description: '' /api/workflow/xero-apps/: get: operationId: workflow_xero_apps_list @@ -9777,7 +9962,7 @@ components: * `OpenAI` - Openai model_name: type: string - description: Specific model name (e.g., gemini-2.5-flash-lite-preview-06-17) + description: Model name (e.g., gemini-flash-latest) maxLength: 100 default: type: boolean @@ -9808,7 +9993,7 @@ components: * `OpenAI` - Openai model_name: type: string - description: Specific model name (e.g., gemini-2.5-flash-lite-preview-06-17) + description: Model name (e.g., gemini-flash-latest) maxLength: 100 default: type: boolean @@ -9839,7 +10024,7 @@ components: * `OpenAI` - Openai model_name: type: string - description: Specific model name (e.g., gemini-2.5-flash-lite-preview-06-17) + description: Model name (e.g., gemini-flash-latest) maxLength: 100 default: type: boolean @@ -9875,7 +10060,7 @@ components: * `OpenAI` - Openai model_name: type: string - description: Specific model name (e.g., gemini-2.5-flash-lite-preview-06-17) + description: Model name (e.g., gemini-flash-latest) maxLength: 100 default: type: boolean @@ -16466,6 +16651,76 @@ components: - job_id - job_name - job_number + NotebookLmLink: + type: object + description: Serializer for NotebookLM training-menu links (read + write). + properties: + id: + type: integer + readOnly: true + name: + type: string + description: Menu item name + maxLength: 100 + url: + type: string + format: uri + description: NotebookLM notebook URL + maxLength: 200 + enabled: + type: boolean + description: Show this link in the training menu + restriction: + allOf: + - $ref: '#/components/schemas/RestrictionEnum' + description: |- + Who may see this link in the menu + + * `none` - All staff + * `superuser` - Superusers only + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Menu display order + required: + - id + - name + - url + NotebookLmLinkRequest: + type: object + description: Serializer for NotebookLM training-menu links (read + write). + properties: + name: + type: string + minLength: 1 + description: Menu item name + maxLength: 100 + url: + type: string + format: uri + minLength: 1 + description: NotebookLM notebook URL + maxLength: 200 + enabled: + type: boolean + description: Show this link in the training menu + restriction: + allOf: + - $ref: '#/components/schemas/RestrictionEnum' + description: |- + Who may see this link in the menu + + * `none` - All staff + * `superuser` - Superusers only + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Menu display order + required: + - name + - url NullEnum: enum: - null @@ -16682,7 +16937,7 @@ components: * `OpenAI` - Openai model_name: type: string - description: Specific model name (e.g., gemini-2.5-flash-lite-preview-06-17) + description: Model name (e.g., gemini-flash-latest) maxLength: 100 default: type: boolean @@ -17270,6 +17525,37 @@ components: minimum: 0 exclusiveMaximum: true description: Company-level rate used to seed JobLabourRate on new jobs + PatchedNotebookLmLinkRequest: + type: object + description: Serializer for NotebookLM training-menu links (read + write). + properties: + name: + type: string + minLength: 1 + description: Menu item name + maxLength: 100 + url: + type: string + format: uri + minLength: 1 + description: NotebookLM notebook URL + maxLength: 200 + enabled: + type: boolean + description: Show this link in the training menu + restriction: + allOf: + - $ref: '#/components/schemas/RestrictionEnum' + description: |- + Who may see this link in the menu + + * `none` - All staff + * `superuser` - Superusers only + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Menu display order PatchedPersonContactMethodWriteRequest: type: object properties: @@ -20122,6 +20408,14 @@ components: description: |- * `merge` - merge * `review` - review + RestrictionEnum: + enum: + - none + - superuser + type: string + description: |- + * `none` - All staff + * `superuser` - Superusers only RoleEnum: enum: - user diff --git a/frontend/scripts/capture-screenshots.ts b/frontend/scripts/capture-screenshots.ts index 2a9d69b94..a8ab698e6 100644 --- a/frontend/scripts/capture-screenshots.ts +++ b/frontend/scripts/capture-screenshots.ts @@ -347,6 +347,9 @@ async function captureSingleScreenshot(options: CliOptions): Promise { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, baseURL: baseUrl, + // Bypass the ngrok-free browser-warning interstitial when the app is + // served through an ngrok tunnel (harmless on non-ngrok hosts). + extraHTTPHeaders: { 'ngrok-skip-browser-warning': 'true' }, }) const failedResponses: PageDiagnostics['failedResponses'] = [] context.on('response', (response) => { @@ -418,6 +421,9 @@ async function captureScreenshots(): Promise { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, baseURL: baseUrl, + // Bypass the ngrok-free browser-warning interstitial when the app is + // served through an ngrok tunnel (harmless on non-ngrok hosts). + extraHTTPHeaders: { 'ngrok-skip-browser-warning': 'true' }, }) const page = await context.newPage() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7a2e7c066..581428429 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,6 +14,7 @@ import { Toaster } from '@/components/ui/sonner' import 'vue-sonner/style.css' import { useFeatureFlags } from './stores/feature-flags' import { useCompanyDefaultsStore } from '@/stores/companyDefaults' +import { useNotebookLmLinksStore } from '@/stores/notebookLmLinks' import { dataFreshness } from '@/composables/useDataFreshness' import { flushSessionReplay, @@ -82,6 +83,8 @@ onMounted(async () => { debugLog('[App] Before loading company defaults:', companyDefaultsStore.companyDefaults) await companyDefaultsStore.loadCompanyDefaults() debugLog('[App] After loading company defaults:', companyDefaultsStore.companyDefaults) + const notebookLmLinksStore = useNotebookLmLinksStore() + await notebookLmLinksStore.loadLinks() // Establish baseline dataset versions; subscribers don't fire on first // observation, only on subsequent changes. dataFreshness.checkFreshness().catch((err) => { diff --git a/frontend/src/api/generated/api.ts b/frontend/src/api/generated/api.ts index 3980d8521..ec40a0bfe 100644 --- a/frontend/src/api/generated/api.ts +++ b/frontend/src/api/generated/api.ts @@ -3547,6 +3547,31 @@ const AppErrorRequest = z.object({ session_replay: z.string().uuid().nullish(), resolved_by: z.string().uuid().nullish(), }) +const RestrictionEnum = z.enum(['none', 'superuser']) +const NotebookLmLink = z.object({ + id: z.number().int(), + name: z.string().max(100), + url: z.string().max(200).url(), + enabled: z.boolean().optional(), + restriction: RestrictionEnum.optional(), + order: z.number().int().gte(-2147483648).lte(2147483647).optional(), +}) +const NotebookLmLinkRequest = z.object({ + name: z.string().min(1).max(100), + url: z.string().min(1).max(200).url(), + enabled: z.boolean().optional(), + restriction: RestrictionEnum.optional(), + order: z.number().int().gte(-2147483648).lte(2147483647).optional(), +}) +const PatchedNotebookLmLinkRequest = z + .object({ + name: z.string().min(1).max(100), + url: z.string().min(1).max(200).url(), + enabled: z.boolean(), + restriction: RestrictionEnum, + order: z.number().int().gte(-2147483648).lte(2147483647), + }) + .partial() const XeroApp = z.object({ id: z.string().uuid(), label: z.string().max(64), @@ -4131,6 +4156,10 @@ export const schemas = { PatchedAIProviderCreateUpdateRequest, AIProviderRequest, AppErrorRequest, + RestrictionEnum, + NotebookLmLink, + NotebookLmLinkRequest, + PatchedNotebookLmLinkRequest, XeroApp, XeroAppCreateRequest, XeroAppCreate, @@ -4643,7 +4672,7 @@ Returns: method: 'get', path: '/api/accounts/staff/:id/', alias: 'accounts_staff_retrieve', - description: `API endpoint for retrieving, updating, and deleting individual staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. Includes comprehensive logging for update operations and handles multipart/form data for file uploads.`, + description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations and handles multipart/form data for file uploads. Staff are not deleted; offboarding is done by setting date_left.`, requestFormat: 'json', parameters: [ { @@ -4658,7 +4687,7 @@ Returns: method: 'put', path: '/api/accounts/staff/:id/', alias: 'accounts_staff_update', - description: `API endpoint for retrieving, updating, and deleting individual staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. Includes comprehensive logging for update operations and handles multipart/form data for file uploads.`, + description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations and handles multipart/form data for file uploads. Staff are not deleted; offboarding is done by setting date_left.`, requestFormat: 'form-data', parameters: [ { @@ -4678,7 +4707,7 @@ Returns: method: 'patch', path: '/api/accounts/staff/:id/', alias: 'accounts_staff_partial_update', - description: `API endpoint for retrieving, updating, and deleting individual staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. Includes comprehensive logging for update operations and handles multipart/form data for file uploads.`, + description: `API endpoint for retrieving and updating individual staff members. Supports GET (retrieve) and PUT/PATCH (update). Includes comprehensive logging for update operations and handles multipart/form data for file uploads. Staff are not deleted; offboarding is done by setting date_left.`, requestFormat: 'form-data', parameters: [ { @@ -4694,21 +4723,6 @@ Returns: ], response: Staff, }, - { - method: 'delete', - path: '/api/accounts/staff/:id/', - alias: 'accounts_staff_destroy', - description: `API endpoint for retrieving, updating, and deleting individual staff members. Supports GET (retrieve), PUT/PATCH (update), and DELETE operations. Includes comprehensive logging for update operations and handles multipart/form data for file uploads.`, - requestFormat: 'json', - parameters: [ - { - name: 'id', - type: 'Path', - schema: z.string().uuid(), - }, - ], - response: z.void(), - }, { method: 'get', path: '/api/accounts/staff/all/', @@ -10366,6 +10380,142 @@ Endpoints: ], response: AppError, }, + { + method: 'get', + path: '/api/workflow/notebook-lm-links/', + alias: 'workflow_notebook_lm_links_list', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + response: z.array(NotebookLmLink), + }, + { + method: 'post', + path: '/api/workflow/notebook-lm-links/', + alias: 'workflow_notebook_lm_links_create', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: NotebookLmLinkRequest, + }, + ], + response: NotebookLmLink, + }, + { + method: 'get', + path: '/api/workflow/notebook-lm-links/:id/', + alias: 'workflow_notebook_lm_links_retrieve', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + parameters: [ + { + name: 'id', + type: 'Path', + schema: z.number().int(), + }, + ], + response: NotebookLmLink, + }, + { + method: 'put', + path: '/api/workflow/notebook-lm-links/:id/', + alias: 'workflow_notebook_lm_links_update', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: NotebookLmLinkRequest, + }, + { + name: 'id', + type: 'Path', + schema: z.number().int(), + }, + ], + response: NotebookLmLink, + }, + { + method: 'patch', + path: '/api/workflow/notebook-lm-links/:id/', + alias: 'workflow_notebook_lm_links_partial_update', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: PatchedNotebookLmLinkRequest, + }, + { + name: 'id', + type: 'Path', + schema: z.number().int(), + }, + ], + response: NotebookLmLink, + }, + { + method: 'delete', + path: '/api/workflow/notebook-lm-links/:id/', + alias: 'workflow_notebook_lm_links_destroy', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + parameters: [ + { + name: 'id', + type: 'Path', + schema: z.number().int(), + }, + ], + response: z.void(), + }, + { + method: 'get', + path: '/api/workflow/notebook-lm-links/menu/', + alias: 'workflow_notebook_lm_links_menu_list', + description: `CRUD for NotebookLM training-menu links. + +Full CRUD is office-staff gated (the admin management surface). The extra +`menu` action is readable by any authenticated staff member and returns only +the enabled links they are allowed to see — the navbar reads that, so the +restriction filtering happens server-side.`, + requestFormat: 'json', + response: z.array(NotebookLmLink), + }, { method: 'get', path: '/api/workflow/xero-apps/', diff --git a/frontend/src/components/AppNavbar.vue b/frontend/src/components/AppNavbar.vue index 0818d314c..458d36358 100644 --- a/frontend/src/components/AppNavbar.vue +++ b/frontend/src/components/AppNavbar.vue @@ -140,12 +140,13 @@ -
+
+
+ Chatbots +
+ + {{ link.name }} + +
@@ -187,15 +205,6 @@ > {{ cat }} -
- - App Training -
@@ -586,7 +595,7 @@
-
+
@@ -648,19 +657,20 @@
-
+
@@ -672,8 +682,25 @@ leave-from-class="opacity-100 max-h-40" leave-to-class="opacity-0 max-h-0" > -
+
+
+ Chatbots +
+ + {{ link.name }} + +
@@ -703,15 +730,6 @@ > {{ cat }} -
- - App Training -
@@ -1023,6 +1041,7 @@ import { import { useAppLayout } from '@/composables/useAppLayout' import { adminPages, adminExternalLinks } from '@/config/adminPages' import { useProcessDocumentsStore } from '@/stores/processDocuments' +import { useNotebookLmLinksStore } from '@/stores/notebookLmLinks' import WorkshopOfficeToggle from '@/components/board/WorkshopOfficeToggle.vue' import SaveStatusIndicator from '@/components/shared/SaveStatusIndicator.vue' @@ -1080,11 +1099,11 @@ watch( const activeDropdown = ref(null) const showMobileMenu = ref(false) -type MobileSection = 'timesheets' | 'purchases' | 'process' | 'crm' | 'reports' | 'admin' +type MobileSection = 'timesheets' | 'purchases' | 'resources' | 'crm' | 'reports' | 'admin' const mobileSections = ref>({ timesheets: false, purchases: false, - process: false, + resources: false, crm: false, reports: false, admin: false, @@ -1094,6 +1113,7 @@ const { userInfo, handleLogout } = useAppLayout() const isOfficeStaff = computed(() => !!userInfo.value?.is_office_staff) const processDocsStore = useProcessDocumentsStore() +const notebookLmLinksStore = useNotebookLmLinksStore() const kanbanNav = computed(() => isOfficeStaff.value @@ -1111,7 +1131,7 @@ const toggleMobileMenu = () => { mobileSections.value = { timesheets: false, purchases: false, - process: false, + resources: false, crm: false, reports: false, admin: false, @@ -1124,7 +1144,7 @@ const closeMobileMenu = () => { mobileSections.value = { timesheets: false, purchases: false, - process: false, + resources: false, crm: false, reports: false, admin: false, diff --git a/frontend/src/components/StaffFormModal.vue b/frontend/src/components/StaffFormModal.vue index 6591a3fea..9ce5e19c7 100644 --- a/frontend/src/components/StaffFormModal.vue +++ b/frontend/src/components/StaffFormModal.vue @@ -133,6 +133,18 @@
+
+
+ + +

Leave blank for current employees

+
+
@@ -394,6 +406,7 @@ const form = ref({ user_permissions: '', last_login: '', date_joined: '', + date_left: '', }) const error = ref('') @@ -458,6 +471,7 @@ watch( : '', last_login: staff.last_login || '', date_joined: staff.date_joined || '', + date_left: staff.date_left || '', } console.log('StaffFormModal - Form populated with:', form.value) console.log( @@ -489,6 +503,7 @@ watch( user_permissions: '', last_login: '', date_joined: '', + date_left: '', } } error.value = '' @@ -521,6 +536,9 @@ async function submitForm() { const dateJoined = normalizeOptionalString(form.value.date_joined) const preferredName = normalizeOptionalString(form.value.preferred_name) const xeroUserId = normalizeOptionalString(form.value.xero_user_id) + // date_left is always sent (null when blank) so an offboarded staff member + // can be reinstated by clearing the field, not just set on offboarding. + const dateLeft = normalizeOptionalString(form.value.date_left) ?? null const baseData: Record = { first_name: form.value.first_name.trim(), @@ -536,6 +554,7 @@ async function submitForm() { hours_fri: form.value.hours_fri, hours_sat: form.value.hours_sat, hours_sun: form.value.hours_sun, + date_left: dateLeft, // Convert groups and user_permissions from strings to arrays groups: form.value.groups && form.value.groups.trim() diff --git a/frontend/src/components/__tests__/AppNavbar.test.ts b/frontend/src/components/__tests__/AppNavbar.test.ts index 3e4fd695c..7e8f6f711 100644 --- a/frontend/src/components/__tests__/AppNavbar.test.ts +++ b/frontend/src/components/__tests__/AppNavbar.test.ts @@ -1,15 +1,17 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { mount } from '@vue/test-utils' import { createMemoryHistory, createRouter } from 'vue-router' import { createPinia } from 'pinia' +const { navLinks, mockUserInfo } = vi.hoisted(() => ({ + navLinks: { value: [] as Array<{ id: number; name: string; url: string }> }, + mockUserInfo: { value: { is_office_staff: true, is_superuser: false } }, +})) + vi.mock('@/composables/useAppLayout', () => ({ useAppLayout: () => ({ - userInfo: ref({ - is_office_staff: true, - is_superuser: false, - }), + userInfo: ref(mockUserInfo.value), handleLogout: vi.fn(), }), })) @@ -24,6 +26,15 @@ vi.mock('@/stores/processDocuments', () => ({ }), })) +vi.mock('@/stores/notebookLmLinks', () => ({ + useNotebookLmLinksStore: () => ({ + get links() { + return navLinks.value + }, + loadLinks: vi.fn(), + }), +})) + import AppNavbar from '../AppNavbar.vue' function buildRouter() { @@ -65,3 +76,85 @@ describe('AppNavbar search URL sync', () => { expect((input.element as HTMLInputElement).value).toBe('') }) }) + +describe('AppNavbar NotebookLM training links', () => { + beforeEach(() => { + mockUserInfo.value = { is_office_staff: true, is_superuser: false } + }) + + async function openResourcesDropdown() { + const router = buildRouter() + await router.push('/kanban') + await router.isReady() + + const wrapper = mount(AppNavbar, { + global: { + plugins: [router, createPinia()], + stubs: { + WorkshopOfficeToggle: true, + }, + }, + }) + + const resourcesButton = wrapper + .findAll('button') + .find((button) => button.text().includes('Resources')) + if (!resourcesButton) throw new Error('Resources dropdown button not found') + await resourcesButton.trigger('click') + + return wrapper + } + + function trainingAnchors(wrapper: Awaited>) { + return wrapper + .findAll('a') + .filter((anchor) => (anchor.attributes('href') ?? '').includes('notebooklm.google.com')) + } + + it('renders one anchor per store link with the correct href and name', async () => { + navLinks.value = [ + { id: 1, name: 'MSM Manual', url: 'https://notebooklm.google.com/notebook/aaa' }, + { id: 2, name: 'Safety Handbook', url: 'https://notebooklm.google.com/notebook/bbb' }, + ] + + const wrapper = await openResourcesDropdown() + const anchors = trainingAnchors(wrapper) + + expect(anchors).toHaveLength(2) + expect(anchors[0].attributes('href')).toBe('https://notebooklm.google.com/notebook/aaa') + expect(anchors[0].text()).toContain('MSM Manual') + expect(anchors[1].attributes('href')).toBe('https://notebooklm.google.com/notebook/bbb') + expect(anchors[1].text()).toContain('Safety Handbook') + }) + + it('renders no training link when the store has no links', async () => { + navLinks.value = [] + + const wrapper = await openResourcesDropdown() + + expect(trainingAnchors(wrapper)).toHaveLength(0) + }) + + it('shows the Resources menu to non-office staff', async () => { + // The `menu` endpoint serves any authenticated staff member and + // NotebookLmRestriction.NONE means "all staff", so the whole Resources + // menu — chatbots, procedures and forms — must not be office-gated. + mockUserInfo.value = { is_office_staff: false, is_superuser: false } + navLinks.value = [ + { id: 1, name: 'MSM Manual', url: 'https://notebooklm.google.com/notebook/aaa' }, + ] + + const wrapper = await openResourcesDropdown() + const anchors = trainingAnchors(wrapper) + + expect(anchors).toHaveLength(1) + expect(anchors[0].attributes('href')).toBe('https://notebooklm.google.com/notebook/aaa') + + // Guards against the mock silently failing to apply: office-only + // navigation must still be hidden for this user. + const purchasesButton = wrapper + .findAll('button') + .find((button) => button.text().includes('Purchases')) + expect(purchasesButton).toBeUndefined() + }) +}) diff --git a/frontend/src/components/admin/AIProviderFormModal.vue b/frontend/src/components/admin/AIProviderFormModal.vue index 8ee17a833..a145caeaa 100644 --- a/frontend/src/components/admin/AIProviderFormModal.vue +++ b/frontend/src/components/admin/AIProviderFormModal.vue @@ -47,11 +47,7 @@
- +

{{ errors.model_name }}

diff --git a/frontend/src/components/admin/NotebookLmLinkFormModal.vue b/frontend/src/components/admin/NotebookLmLinkFormModal.vue new file mode 100644 index 000000000..3c54b1ddf --- /dev/null +++ b/frontend/src/components/admin/NotebookLmLinkFormModal.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/components/chat/README.md b/frontend/src/components/chat/README.md index 7ec3a709b..fa95e06e8 100644 --- a/frontend/src/components/chat/README.md +++ b/frontend/src/components/chat/README.md @@ -76,7 +76,7 @@ interface ToolCall { interface McpMetadata { tool_calls?: ToolCall[] // Array of executed tool calls tool_definitions?: ToolDefinition[] // Available tools for session - model?: string // AI model used (e.g., "gemini-1.5-pro") + model?: string // AI model used (e.g., "gemini-flash-latest") system_prompt?: string // System prompt used user_message?: string // Original user message chat_history?: any[] // Conversation history diff --git a/frontend/src/composables/useStaffApi.ts b/frontend/src/composables/useStaffApi.ts index b3be46979..eda5c21ea 100644 --- a/frontend/src/composables/useStaffApi.ts +++ b/frontend/src/composables/useStaffApi.ts @@ -65,20 +65,6 @@ export function useStaffApi() { } } - async function removeStaff(id: string | number): Promise { - error.value = null - try { - await api.accounts_staff_destroy(undefined, { params: { id: String(id) } }) - } catch (e: unknown) { - if (e instanceof Error) { - error.value = e.message - } else { - error.value = 'Failed to delete staff.' - } - throw e - } - } - async function listStaffForKanban(): Promise { error.value = null try { @@ -104,7 +90,6 @@ export function useStaffApi() { listStaffForKanban, createStaff, updateStaff, - removeStaff, error, } } diff --git a/frontend/src/config/adminPages.ts b/frontend/src/config/adminPages.ts index 83a4de90b..4b370d91a 100644 --- a/frontend/src/config/adminPages.ts +++ b/frontend/src/config/adminPages.ts @@ -7,6 +7,7 @@ import { Bot, Brain, ExternalLink, + GraduationCap, KeyRound, MonitorPlay, Wrench, @@ -77,6 +78,13 @@ const adminPagesConfig = [ icon: Brain, view: 'AdminAIProvidersView', }, + { + key: 'notebooklm-links', + label: 'NotebookLM Links', + title: 'NotebookLM Links', + icon: GraduationCap, + view: 'AdminNotebookLmLinksView', + }, { key: 'xero-apps', label: 'Xero Apps', diff --git a/frontend/src/services/notebookLmLinkService.ts b/frontend/src/services/notebookLmLinkService.ts new file mode 100644 index 000000000..340d3aa48 --- /dev/null +++ b/frontend/src/services/notebookLmLinkService.ts @@ -0,0 +1,87 @@ +import { schemas } from '@/api/generated/api' +import { api } from '@/api/client' +import { debugLog } from '@/utils/debug' +import { z } from 'zod' + +export type NotebookLmLink = z.infer +export type NotebookLmLinkCreateUpdate = z.infer + +export class NotebookLmLinkService { + private static instance: NotebookLmLinkService + + public static getInstance(): NotebookLmLinkService { + if (!NotebookLmLinkService.instance) { + NotebookLmLinkService.instance = new NotebookLmLinkService() + } + return NotebookLmLinkService.instance + } + + private constructor() {} + + async getLinks(): Promise { + try { + return await api.workflow_notebook_lm_links_list() + } catch (error) { + debugLog('Failed to fetch NotebookLM links:', error) + throw error + } + } + + /** + * The enabled links the current user is allowed to see. Restriction + * filtering happens server-side, so this is what the navbar renders. + */ + async getMenuLinks(): Promise { + try { + return await api.workflow_notebook_lm_links_menu_list() + } catch (error) { + debugLog('Failed to fetch NotebookLM menu links:', error) + throw error + } + } + + async createLink(linkData: NotebookLmLinkCreateUpdate): Promise { + try { + const created = await api.workflow_notebook_lm_links_create(linkData) + return schemas.NotebookLmLink.parse(created) + } catch (error) { + debugLog('Failed to create NotebookLM link:', error) + throw error + } + } + + async updateLink( + id: number, + linkData: Partial, + ): Promise { + try { + const updated = await api.workflow_notebook_lm_links_partial_update(linkData, { + params: { id }, + }) + return schemas.NotebookLmLink.parse(updated) + } catch (error) { + debugLog(`Failed to update NotebookLM link ${id}:`, error) + throw error + } + } + + async deleteLink(id: number): Promise { + try { + await api.workflow_notebook_lm_links_destroy(undefined, { params: { id } }) + } catch (error) { + debugLog(`Failed to delete NotebookLM link ${id}:`, error) + throw error + } + } + + async getLink(id: number): Promise { + try { + return await api.workflow_notebook_lm_links_retrieve({ params: { id } }) + } catch (error) { + debugLog(`Failed to get NotebookLM link ${id}:`, error) + throw error + } + } +} + +export const notebookLmLinkService = NotebookLmLinkService.getInstance() diff --git a/frontend/src/stores/__tests__/notebookLmLinks.test.ts b/frontend/src/stores/__tests__/notebookLmLinks.test.ts new file mode 100644 index 000000000..7fb33ae7b --- /dev/null +++ b/frontend/src/stores/__tests__/notebookLmLinks.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const { getMenuLinks } = vi.hoisted(() => ({ getMenuLinks: vi.fn() })) + +vi.mock('@/services/notebookLmLinkService', () => ({ + notebookLmLinkService: { getMenuLinks }, +})) + +import { useNotebookLmLinksStore } from '../notebookLmLinks' + +describe('notebookLmLinks store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + getMenuLinks.mockReset() + }) + + it('loads the menu links through the service layer', async () => { + const links = [{ id: 1, name: 'MSM Manual', url: 'https://notebooklm.google.com/notebook/aaa' }] + getMenuLinks.mockResolvedValue(links) + + const store = useNotebookLmLinksStore() + await store.loadLinks() + + expect(getMenuLinks).toHaveBeenCalledTimes(1) + expect(store.links).toEqual(links) + expect(store.isLoaded).toBe(true) + expect(store.isLoading).toBe(false) + expect(store.error).toBeNull() + }) + + it('surfaces a failure without leaving the store loading', async () => { + getMenuLinks.mockRejectedValue(new Error('network down')) + + const store = useNotebookLmLinksStore() + await store.loadLinks() + + expect(store.error).toBe('network down') + expect(store.isLoaded).toBe(false) + expect(store.isLoading).toBe(false) + }) +}) diff --git a/frontend/src/stores/notebookLmLinks.ts b/frontend/src/stores/notebookLmLinks.ts new file mode 100644 index 000000000..cac53e08c --- /dev/null +++ b/frontend/src/stores/notebookLmLinks.ts @@ -0,0 +1,31 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { notebookLmLinkService, type NotebookLmLink } from '@/services/notebookLmLinkService' + +export const useNotebookLmLinksStore = defineStore('notebookLmLinks', () => { + const links = ref([]) + const isLoaded = ref(false) + const isLoading = ref(false) + const error = ref(null) + + async function loadLinks() { + isLoading.value = true + error.value = null + try { + links.value = await notebookLmLinkService.getMenuLinks() + isLoaded.value = true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load NotebookLM links' + } finally { + isLoading.value = false + } + } + + return { + links, + isLoaded, + isLoading, + error, + loadLinks, + } +}) diff --git a/frontend/src/views/AdminNotebookLmLinksView.vue b/frontend/src/views/AdminNotebookLmLinksView.vue new file mode 100644 index 000000000..edc3b7d28 --- /dev/null +++ b/frontend/src/views/AdminNotebookLmLinksView.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/views/AdminStaffView.vue b/frontend/src/views/AdminStaffView.vue index 8fe49a430..e16b5dfe0 100644 --- a/frontend/src/views/AdminStaffView.vue +++ b/frontend/src/views/AdminStaffView.vue @@ -88,13 +88,6 @@ > - @@ -113,13 +106,6 @@ @close="closeModal" @saved="onSaved" /> -
@@ -130,20 +116,18 @@ import Button from '@/components/ui/button/Button.vue' import { ref, computed, onMounted } from 'vue' import { useStaffApi } from '@/composables/useStaffApi' import StaffFormModal from '@/components/StaffFormModal.vue' -import ConfirmModal from '@/components/ConfirmModal.vue' import { schemas } from '@/api/generated/api' -import { PencilLine, Trash2 } from 'lucide-vue-next' +import { PencilLine } from 'lucide-vue-next' import { formatDateTime } from '@/utils/string-formatting' import type { z } from 'zod' type Staff = z.infer -const { listStaff, removeStaff } = useStaffApi() +const { listStaff } = useStaffApi() const staffList = ref([]) const loading = ref(true) const search = ref('') const showModal = ref(false) -const showConfirm = ref(false) const selectedStaff = ref(null) const filteredStaff = computed(() => @@ -196,20 +180,6 @@ function onSaved() { fetchStaff() closeModal() } -function confirmDelete(staff: Staff) { - selectedStaff.value = staff - showConfirm.value = true -} -function closeConfirm() { - showConfirm.value = false - selectedStaff.value = null -} -async function deleteStaff() { - if (!selectedStaff.value) return - await removeStaff(selectedStaff.value.id) - fetchStaff() - closeConfirm() -} async function fetchStaff() { loading.value = true diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 587fd1b4e..2c383445f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -116,15 +116,6 @@ export default defineConfig(({ mode }) => { target: 'http://localhost:8000', changeOrigin: true, }, - // VitePress training manual dev server (npm run manual:dev). In prod - // nginx serves /manual/ from dist-manual/; in dev we proxy to the - // VitePress dev server so the navbar "App Training" link works and - // manual content hot-reloads. ws:true keeps VitePress HMR alive. - '/manual': { - target: 'http://localhost:5174', - changeOrigin: true, - ws: true, - }, }, }, } diff --git a/mypy-baseline.txt b/mypy-baseline.txt index bd3c6ed4c..f37dd7267 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -343,8 +343,6 @@ apps/quoting/tests_utils.py:0: error: Function is missing a return type annotati apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/services/pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/services/pdf_data_validation.py:0: note: Use "-> None" if function does not return a value apps/quoting/services/pdf_data_validation.py:0: error: Returning Any from function declared to return "str" [no-any-return] apps/purchasing/services/stock_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] apps/purchasing/services/stock_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] @@ -792,11 +790,6 @@ apps/accounting/services/wip_service.py:0: error: Argument "key" to "sorted" has apps/accounting/services/wip_service.py:0: error: Incompatible return value type (got "object", expected "SupportsDunderLT[Any] | SupportsDunderGT[Any]") [return-value] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible default for parameter "model_name" (default has type "None", parameter has type "str") [assignment] -apps/quoting/services/ai_price_extraction.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True -apps/quoting/services/ai_price_extraction.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "MistralPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "GeminiPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] apps/quoting/services/ai_price_extraction.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/services/ai_price_extraction.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "int" [call-overload] apps/quoting/services/ai_price_extraction.py:0: note: Possible overload variants: @@ -1719,7 +1712,6 @@ apps/quoting/tests_mcp.py:0: note: Use "-> None" if function does not return a v apps/quoting/tests_mcp.py:0: error: Call to untyped function "get_queryset" in typed context [no-untyped-call] apps/quoting/tests/test_pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/tests/test_pdf_data_validation.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests/test_pdf_data_validation.py:0: error: Call to untyped function "PDFDataValidationService" in typed context [no-untyped-call] apps/quoting/tests/test_pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/tests/test_pdf_data_validation.py:0: note: Use "-> None" if function does not return a value apps/quoting/tests/test_pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -2882,7 +2874,6 @@ apps/job/services/chat_service.py:0: error: Item "None" of "Company | None" has apps/job/services/chat_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] apps/job/services/chat_service.py:0: error: Cannot call function of unknown type [operator] apps/quoting/views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/views.py:0: error: Call to untyped function "PDFDataValidationService" in typed context [no-untyped-call] apps/quoting/views.py:0: error: Call to untyped function "PDFImportService" in typed context [no-untyped-call] apps/quoting/scrapers/steel_and_tube.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/scrapers/steel_and_tube.py:0: error: "None" has no attribute "get" [attr-defined] @@ -3030,7 +3021,6 @@ apps/accounts/views/staff_api.py:0: error: Missing type arguments for generic ty apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/accounts/views/staff_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/views/staff_api.py:0: error: Missing type arguments for generic type "RetrieveUpdateDestroyAPIView" [type-arg] apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/accounts/views/staff_api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/purchasing/views/supplier_search_rest_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] diff --git a/scripts/README.md b/scripts/README.md index 899e59cc6..d63d67383 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -19,7 +19,10 @@ Run manually for periodic code quality analysis: Require `GCP_CREDENTIALS` env var pointing to a service account JSON file: -- **`explore_google_drive.py`** — Browse Google Drive folder structure +- **`explore_google_drive.py`** — Browse the MSM Google Drive layout (Shared Drives included). No args lists Shared Drives; pass a driveId to walk its tree. +- **`read_google_doc.py`** — Print a Google Doc's content as Markdown. Args: ``. +- **`write_google_doc.py`** — Write/replace a Google Doc from Markdown, with a revisionId safety net that refuses to overwrite a doc a human has edited. Subcommands: `import `, `seed <doc_id>`, `trash <doc_id>`, `status`. +- **`set_doc_screenshot.py`** — Push a captured PNG into a Google Doc at its `{{screenshot:<id>}}` marker (the push half of the screenshot pipeline; capture half is `frontend/scripts/capture-screenshots.ts`). Args: `<doc_id> <screenshot_id> <png_path>`. - **`create_master_template.py`** — Create/manage Google Sheets quote templates - **`get_gapi_token.py`** — Print a Google API access token for debugging diff --git a/scripts/explore_google_drive.py b/scripts/explore_google_drive.py index f47975f66..caa126470 100644 --- a/scripts/explore_google_drive.py +++ b/scripts/explore_google_drive.py @@ -1,224 +1,141 @@ -""" -Script to explore Google Drive folder structure -and discover the root folder and other useful information. +"""Browse the MSM Google Drive layout (Shared Drives included). + +Read-only. Prints the Shared Drives visible to the delegated user and, when +given a driveId, walks that drive's folder/file tree. + +The content we care about (the Operations Manual) lives in a Shared Drive, not +in anyone's My Drive, so this must impersonate a real Workspace user and pass +the Shared-Drive flags on every call — raw service-account creds see only the +service account's empty My Drive, and `root`/`about` never expose Shared Drives. + +Auth follows the app convention (apps/job/importers/google_sheets.py): the +service-account key comes from the GCP_CREDENTIALS env var and the impersonated +subject defaults to CompanyDefaults.company_email — the per-instance Workspace +user domain-wide delegation acts as. Set GCP_DELEGATED_SUBJECT to override the +subject when pointing at a Drive whose real user differs from this instance's +company_email (e.g. a dev box browsing a client's Shared Drive — the dev DB's +company_email is a demo placeholder that is not a real Workspace user). Both the +key and the resolved subject fail loud if missing. Domain-wide delegation matches +scope strings literally. + +Usage: + GCP_CREDENTIALS=<key.json> python scripts/explore_google_drive.py + GCP_CREDENTIALS=<key.json> python scripts/explore_google_drive.py <driveId> """ -import json import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() from google.oauth2 import service_account from googleapiclient.discovery import build +from apps.workflow.models import CompanyDefaults + SCOPES = ["https://www.googleapis.com/auth/drive"] +FOLDER_MIME = "application/vnd.google-apps.folder" -def get_drive_service(): - """Initialise the Google Drive service.""" +def build_drive(): + """Authenticated Drive client, impersonating CompanyDefaults.company_email.""" key_file = os.getenv("GCP_CREDENTIALS") if not key_file: raise RuntimeError("GCP_CREDENTIALS environment variable not set") if not os.path.exists(key_file): raise RuntimeError(f"Google service account key file not found: {key_file}") + subject = ( + os.getenv("GCP_DELEGATED_SUBJECT") or CompanyDefaults.get_solo().company_email + ) + if not subject: + raise RuntimeError( + "No impersonation subject: set GCP_DELEGATED_SUBJECT or populate " + "CompanyDefaults.company_email in Settings. Google Workspace " + "domain-wide delegation needs a real Workspace user to impersonate." + ) creds = service_account.Credentials.from_service_account_file( key_file, scopes=SCOPES - ) + ).with_subject(subject) return build("drive", "v3", credentials=creds) -def get_root_folder(service): - """Get information about the root folder.""" - try: - # Search for the root folder - root = ( - service.files() - .get(fileId="root", fields="id, name, mimeType, webViewLink") - .execute() - ) - print("=== ROOT FOLDER ===") - print(f"ID: {root.get('id')}") - print(f"Name: {root.get('name')}") - print(f"Type: {root.get('mimeType')}") - print(f"Link: {root.get('webViewLink')}") - print() - return root.get("id") - except Exception as e: - print(f"Error searching for root folder: {e}") - return None - - -def list_folders(service, parent_id="root", max_results=50): - """List folders in a specific directory.""" - try: - query = f"'{parent_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false" - - results = ( - service.files() - .list( - q=query, - pageSize=max_results, - fields="nextPageToken, files(id, name, mimeType, webViewLink, parents)", - ) - .execute() - ) - - items = results.get("files", []) - - if not items: - print(f"No folders found in {parent_id}") - return [] - - print(f"=== FOLDERS IN {parent_id} ===") - for item in items: - print(f"Name: {item['name']}") - print(f"ID: {item['id']}") - print(f"Link: {item.get('webViewLink', 'N/A')}") - print(f"Parents: {item.get('parents', [])}") - print("-" * 50) - - return items - - except Exception as e: - print(f"Error listing folders: {e}") - return [] +drive = build_drive() -def list_files(service, parent_id="root", max_results=20): - """List files in a specific directory.""" - try: - query = f"'{parent_id}' in parents and mimeType!='application/vnd.google-apps.folder' and trashed=false" - - results = ( - service.files() +def list_shared_drives() -> None: + """Print every Shared Drive the delegated user can see.""" + print("=== SHARED DRIVES ===") + token = None + while True: + resp = ( + drive.drives() .list( - q=query, - pageSize=max_results, - fields="nextPageToken, files(id, name, mimeType, webViewLink, parents)", + pageSize=100, fields="nextPageToken, drives(id, name)", pageToken=token ) .execute() ) - - items = results.get("files", []) - - if not items: - print(f"No files found in {parent_id}") - return [] - - print(f"=== FILES IN {parent_id} ===") - for item in items: - print(f"Name: {item['name']}") - print(f"ID: {item['id']}") - print(f"Type: {item['mimeType']}") - print(f"Link: {item.get('webViewLink', 'N/A')}") - print("-" * 50) - - return items - - except Exception as e: - print(f"Error listing files: {e}") - return [] - - -def search_by_name(service, name, file_type=None): - """Search for files/folders by name.""" - try: - query = f"name contains '{name}' and trashed=false" - - if file_type == "folder": - query += " and mimeType='application/vnd.google-apps.folder'" - elif file_type == "spreadsheet": - query += " and mimeType='application/vnd.google-apps.spreadsheet'" - - results = ( - service.files() + for d in resp.get("drives", []): + print(f"{d['name']}\t{d['id']}") + token = resp.get("nextPageToken") + if not token: + break + print("\nRun again with a driveId to walk that drive's tree.") + + +def children(parent_id: str, drive_id: str) -> list: + """All non-trashed children of parent_id within a Shared Drive, paged.""" + items = [] + token = None + while True: + resp = ( + drive.files() .list( - q=query, - pageSize=50, - fields="nextPageToken, files(id, name, mimeType, webViewLink, parents)", + q=f"'{parent_id}' in parents and trashed = false", + corpora="drive", + driveId=drive_id, + includeItemsFromAllDrives=True, + supportsAllDrives=True, + pageSize=1000, + fields="nextPageToken, files(id, name, mimeType)", + orderBy="folder,name", + pageToken=token, ) .execute() ) - - items = results.get("files", []) - - print(f"=== SEARCH FOR '{name}' ===") - if not items: - print("No results found") - return [] - - for item in items: - print(f"Name: {item['name']}") - print(f"ID: {item['id']}") - print(f"Type: {item['mimeType']}") - print(f"Link: {item.get('webViewLink', 'N/A')}") - print(f"Parents: {item.get('parents', [])}") - print("-" * 50) - - return items - - except Exception as e: - print(f"Error in search: {e}") - return [] - - -def get_drive_info(service): - """Get general information about the drive.""" - try: - about = service.about().get(fields="user, storageQuota").execute() - print("=== DRIVE INFORMATION ===") - print(f"User: {about.get('user', {}).get('displayName', 'N/A')}") - print(f"Email: {about.get('user', {}).get('emailAddress', 'N/A')}") - - quota = about.get("storageQuota", {}) - if quota: - limit = int(quota.get("limit", 0)) - usage = int(quota.get("usage", 0)) - print(f"Storage used: {usage / (1024**3):.2f} GB") - print(f"Limit: {limit / (1024**3):.2f} GB") - print() - - except Exception as e: - print(f"Error getting drive information: {e}") - - -def main(): - """Main function.""" - print("🔍 Exploring Google Drive...") - print("=" * 60) - - service = get_drive_service() - - # General information - get_drive_info(service) - - # Root folder - root_id = get_root_folder(service) - - if root_id: - # List folders in root - folders = list_folders(service, root_id) - - # List some files in root - files = list_files(service, root_id, max_results=10) - - # Search for existing templates - print("\n" + "=" * 60) - print("🔍 Searching for existing templates...") - search_by_name(service, "template", "spreadsheet") - search_by_name(service, "quote", "spreadsheet") - - # Save information to JSON file - drive_info = { - "root_id": root_id, - "folders": folders, - "files": files[:5], # Only first 5 files - "timestamp": "2025-07-20", - } - - with open("drive_structure.json", "w", encoding="utf-8") as f: - json.dump(drive_info, f, indent=2, ensure_ascii=False) - - print("\n✅ Information saved to 'drive_structure.json'") - print(f"📁 Root folder ID: {root_id}") + items.extend(resp.get("files", [])) + token = resp.get("nextPageToken") + if not token: + break + return items + + +def walk(parent_id: str, drive_id: str, depth: int) -> None: + """Print an indented tree of parent_id's descendants.""" + for item in children(parent_id, drive_id): + indent = " " * depth + is_folder = item["mimeType"] == FOLDER_MIME + marker = "📁" if is_folder else " " + print(f"{indent}{marker} {item['name']}\t{item['id']}\t{item['mimeType']}") + if is_folder: + walk(item["id"], drive_id, depth + 1) + + +def walk_drive(drive_id: str) -> None: + meta = drive.drives().get(driveId=drive_id, fields="id, name").execute() + print(f"=== {meta['name']} ({drive_id}) ===") + walk(drive_id, drive_id, 0) + + +def main() -> None: + if len(sys.argv) > 1: + walk_drive(sys.argv[1]) + else: + list_shared_drives() if __name__ == "__main__": diff --git a/scripts/read_google_doc.py b/scripts/read_google_doc.py new file mode 100644 index 000000000..932b6bf0e --- /dev/null +++ b/scripts/read_google_doc.py @@ -0,0 +1,58 @@ +"""Print a Google Doc's text (exported as Markdown) via the service account. + +Read companion to explore_google_drive.py — that lists the Drive tree, this +reads a document's content. Same delegated auth (GCP_CREDENTIALS + +CompanyDefaults.company_email, GCP_DELEGATED_SUBJECT override). + +Usage: + GCP_CREDENTIALS=<key.json> python scripts/read_google_doc.py <doc_id> +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from google.oauth2 import service_account +from googleapiclient.discovery import build + +from apps.workflow.models import CompanyDefaults + +# read_doc() exports through the Drive API and never touches a Docs resource. +SCOPES = ["https://www.googleapis.com/auth/drive"] + + +def build_drive(): + key_file = os.getenv("GCP_CREDENTIALS") + if not key_file: + raise RuntimeError("GCP_CREDENTIALS environment variable not set") + if not os.path.exists(key_file): + raise RuntimeError(f"Google service account key file not found: {key_file}") + subject = ( + os.getenv("GCP_DELEGATED_SUBJECT") or CompanyDefaults.get_solo().company_email + ) + if not subject: + raise RuntimeError( + "No impersonation subject: set GCP_DELEGATED_SUBJECT or populate " + "CompanyDefaults.company_email in Settings." + ) + creds = service_account.Credentials.from_service_account_file( + key_file, scopes=SCOPES + ).with_subject(subject) + return build("drive", "v3", credentials=creds) + + +def read_doc(doc_id: str) -> str: + data = ( + build_drive().files().export(fileId=doc_id, mimeType="text/markdown").execute() + ) + return data.decode("utf-8") if isinstance(data, bytes) else str(data) + + +if __name__ == "__main__": + print(read_doc(sys.argv[1])) diff --git a/scripts/server/release-utils.sh b/scripts/server/release-utils.sh index b4364f2a4..c82f5e930 100755 --- a/scripts/server/release-utils.sh +++ b/scripts/server/release-utils.sh @@ -233,7 +233,6 @@ ensure_release() { npm ci --include=dev --cache '$BASE_DIR/.npm-cache' npm run check:typed-router npm run build - npm run manual:build rm -rf node_modules touch '$release_dir/.complete' "; then diff --git a/scripts/server/templates/ai-providers.json.template b/scripts/server/templates/ai-providers.json.template index 321b8f6f2..5477c960f 100644 --- a/scripts/server/templates/ai-providers.json.template +++ b/scripts/server/templates/ai-providers.json.template @@ -17,7 +17,7 @@ "name": "Gemini", "provider_type": "Gemini", "api_key": "__GEMINI_API_KEY__", - "model_name": "gemini-2.5-flash", + "model_name": "gemini-flash-latest", "default": false } }, diff --git a/scripts/server/templates/nginx-instance.conf.template b/scripts/server/templates/nginx-instance.conf.template index d2169c600..5e5b20465 100644 --- a/scripts/server/templates/nginx-instance.conf.template +++ b/scripts/server/templates/nginx-instance.conf.template @@ -21,16 +21,6 @@ server { alias /opt/docketworks/instances/__INSTANCE__/mediafiles/; } - # VitePress training manual (shared release build). - # Do NOT use try_files with alias — nginx 1.24 has a known bug where - # try_files builds the fallback path with root logic instead of alias, - # which 500s when dist-manual/ is missing. VitePress is built with - # cleanUrls off, so `index` alone covers directory requests. - location /manual/ { - alias /opt/docketworks/instances/__INSTANCE__/app/frontend/dist-manual/; - index index.html; - } - # SPA entry point — never cache, otherwise a hard reload fetches a stale # HTML that still references the previous build's hashed /assets/*. # index.html is the only pointer to a specific build. diff --git a/scripts/set_doc_screenshot.py b/scripts/set_doc_screenshot.py new file mode 100644 index 000000000..0d1ca72d3 --- /dev/null +++ b/scripts/set_doc_screenshot.py @@ -0,0 +1,162 @@ +"""Set a captured screenshot into a Google Doc at its {{screenshot:<id>}} marker. + +Finds the marker text, uploads the PNG to Drive, inserts it as an inline image +at the marker, and deletes the marker text. If the marker is already gone (image +previously set) it reports and does nothing — re-capturing into an existing image +is a separate replaceImage path (not yet built). + +This is the push half of the screenshot pipeline; the capture half is +frontend/scripts/capture-screenshots.ts (run via `npm run manual:screenshots`). + +Auth follows the app convention (apps/job/importers/google_sheets.py): key from +the GCP_CREDENTIALS env var, subject from CompanyDefaults.company_email, with a +GCP_DELEGATED_SUBJECT env override for pointing a dev box at a client's Drive +(the dev DB's company_email is a demo placeholder, not a real Workspace user). + +Usage: + GCP_CREDENTIALS=<key.json> python scripts/set_doc_screenshot.py \ + <doc_id> <screenshot_id> <png_path> +""" + +import io +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseUpload +from PIL import Image + +from apps.workflow.models import CompanyDefaults + +SCOPES = [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/documents", +] + +# Fit the image to a typical Google Doc content width. +MAX_WIDTH_PT = 460.0 + + +def build_services(): + """Drive + Docs clients, impersonating the resolved Workspace subject.""" + key_file = os.getenv("GCP_CREDENTIALS") + if not key_file: + raise RuntimeError("GCP_CREDENTIALS environment variable not set") + if not os.path.exists(key_file): + raise RuntimeError(f"Google service account key file not found: {key_file}") + subject = ( + os.getenv("GCP_DELEGATED_SUBJECT") or CompanyDefaults.get_solo().company_email + ) + if not subject: + raise RuntimeError( + "No impersonation subject: set GCP_DELEGATED_SUBJECT or populate " + "CompanyDefaults.company_email in Settings. Google Workspace " + "domain-wide delegation needs a real Workspace user to impersonate." + ) + creds = service_account.Credentials.from_service_account_file( + key_file, scopes=SCOPES + ).with_subject(subject) + return build("drive", "v3", credentials=creds), build( + "docs", "v1", credentials=creds + ) + + +drive, docs = build_services() + + +def find_marker(doc: dict, marker: str): + for el in doc.get("body", {}).get("content", []): + para = el.get("paragraph") + if not para: + continue + for pe in para.get("elements", []): + tr = pe.get("textRun") + if not tr: + continue + idx = tr.get("content", "").find(marker) + if idx != -1: + start = pe["startIndex"] + idx + return start, start + len(marker) + return None + + +def upload_png(png_path: str) -> str: + with open(png_path, "rb") as fh: + data = fh.read() + media = MediaIoBaseUpload(io.BytesIO(data), mimetype="image/png", resumable=False) + f = ( + drive.files() + .create(body={"name": "screenshot-tmp.png"}, media_body=media, fields="id") + .execute() + ) + fid = f["id"] + drive.permissions().create( + fileId=fid, body={"type": "anyone", "role": "reader"} + ).execute() + return fid + + +def main(doc_id: str, screenshot_id: str, png_path: str) -> int: + marker = "{{screenshot:%s}}" % screenshot_id + doc = docs.documents().get(documentId=doc_id).execute() + found = find_marker(doc, marker) + if not found: + print(f"marker {marker} not found in doc (already set?). Nothing to do.") + return 1 + start, end = found + + w, h = Image.open(png_path).size + disp_w = min(MAX_WIDTH_PT, float(w)) + disp_h = disp_w * h / w + + fid = upload_png(png_path) + uri = f"https://drive.google.com/uc?export=download&id={fid}" + try: + docs.documents().batchUpdate( + documentId=doc_id, + body={ + "requests": [ + { + "insertInlineImage": { + "location": {"index": start}, + "uri": uri, + "objectSize": { + "width": {"magnitude": disp_w, "unit": "PT"}, + "height": {"magnitude": disp_h, "unit": "PT"}, + }, + } + }, + { + "deleteContentRange": { + "range": {"startIndex": start + 1, "endIndex": end + 1} + } + }, + ] + }, + ).execute() + finally: + # The upload is world-readable so Docs can fetch it, and Docs keeps its + # own copy once inserted. Only a permanent delete revokes that public + # grant — trashing leaves it live. + drive.files().delete(fileId=fid).execute() + + after = docs.documents().get(documentId=doc_id).execute() + n_images = len(after.get("inlineObjects", {})) + marker_gone = find_marker(after, marker) is None + print( + f"inserted image ({disp_w:.0f}x{disp_h:.0f} pt); doc now has {n_images} " + f"inline image(s); marker removed: {marker_gone}" + ) + return 0 if marker_gone else 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1], sys.argv[2], sys.argv[3])) diff --git a/scripts/write_google_doc.py b/scripts/write_google_doc.py new file mode 100644 index 000000000..dd7367a34 --- /dev/null +++ b/scripts/write_google_doc.py @@ -0,0 +1,266 @@ +"""Write/replace a Google Doc from Markdown, WITH an overwrite safety net. + +Write companion to read_google_doc.py. Imports a Markdown file as a Google Doc +(headings, bold, lists, tables and {{screenshot:id}} markers survive), and will +only ever replace or trash a doc that: + (a) this tool created or was told to manage (recorded in the manifest), AND + (b) has NOT been edited since this tool last wrote it + (current Docs content revisionId == the revisionId recorded after our write). + +The signal is the Docs content revisionId (edit history), NOT modifiedTime: +revisionId changes only on a real content edit, so it ignores the async metadata +mtime bump Drive applies after an import. lastModifyingUser is useless here — the +service account writes by impersonating a human, so every change shows that +human's address regardless of who actually made it. + +Any doc not in the manifest (human-authored / pre-existing), or any manifest doc +whose revisionId has changed (a human edited it), is REFUSED. To manage an +existing human doc, `seed` it first (baselines its current revision); a later +`import` then replaces it, refusing if a human edited it in between. + +Auth follows the app convention (GCP_CREDENTIALS + CompanyDefaults.company_email, +GCP_DELEGATED_SUBJECT override), same as read_google_doc.py. + +Usage: + write_google_doc.py import <md_path> <folder_id> <title> + write_google_doc.py seed <doc_id> # baseline an existing doc so it can be managed + write_google_doc.py trash <doc_id> # trash a managed doc (if unedited since our write) + write_google_doc.py status # show manifest vs live state +""" + +import io +import json +import os +import sys +from typing import TypedDict + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import MediaIoBaseUpload + +from apps.workflow.models import CompanyDefaults + +# One manifest entry: what this tool wrote, where, and the revision it left +# behind (the edit-detection baseline). +ManifestEntry = TypedDict( + "ManifestEntry", {"title": str, "folder_id": str, "revisionId": str} +) + +SCOPES = [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/documents", +] +# Per-instance state (which docs this tool manages + their post-write revisionId). +# Gitignored — it is runtime data, not source. +MANIFEST = os.path.join(os.path.dirname(__file__), "google_doc_manifest.json") + + +def _clients(): + key_file = os.getenv("GCP_CREDENTIALS") + if not key_file: + raise RuntimeError("GCP_CREDENTIALS environment variable not set") + if not os.path.exists(key_file): + raise RuntimeError(f"Google service account key file not found: {key_file}") + subject = ( + os.getenv("GCP_DELEGATED_SUBJECT") or CompanyDefaults.get_solo().company_email + ) + if not subject: + raise RuntimeError( + "No impersonation subject: set GCP_DELEGATED_SUBJECT or populate " + "CompanyDefaults.company_email in Settings." + ) + creds = service_account.Credentials.from_service_account_file( + key_file, scopes=SCOPES + ).with_subject(subject) + return build("drive", "v3", credentials=creds), build( + "docs", "v1", credentials=creds + ) + + +drive, docs = _clients() + + +def load() -> dict[str, ManifestEntry]: + if os.path.exists(MANIFEST): + with open(MANIFEST) as fh: + return json.load(fh) + return {} + + +def save(manifest: dict[str, ManifestEntry]) -> None: + with open(MANIFEST, "w") as fh: + json.dump(manifest, fh, indent=2, sort_keys=True) + + +def revid(doc_id: str) -> str: + """Docs content revisionId — changes only on a real content edit.""" + return ( + docs.documents() + .get(documentId=doc_id, fields="revisionId") + .execute()["revisionId"] + ) + + +def q_literal(value: str) -> str: + """Escape a value for use inside a Drive query string literal. + + Drive's query grammar takes backslash escapes, so a perfectly ordinary + title like "Driver's Handbook" would otherwise terminate the literal early + and make the whole query invalid. + """ + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def find_in_folder(folder_id: str, title: str) -> list[dict[str, str]]: + return ( + drive.files() + .list( + q=( + f"name = '{q_literal(title)}' " + f"and '{q_literal(folder_id)}' in parents and trashed = false " + "and mimeType = 'application/vnd.google-apps.document'" + ), + fields="files(id)", + includeItemsFromAllDrives=True, + supportsAllDrives=True, + ) + .execute() + .get("files", []) + ) + + +class OverwriteRefused(Exception): + pass + + +def check_unedited(doc_id: str, manifest: dict[str, ManifestEntry]) -> None: + """Raise unless doc_id is managed by this tool and unedited since our write.""" + rec = manifest.get(doc_id) + if rec is None: + raise OverwriteRefused(f"{doc_id} is not managed by this tool. Refusing.") + if rec["revisionId"] != revid(doc_id): + raise OverwriteRefused( + f"{doc_id} ('{rec['title']}') has been edited since this tool wrote " + f"it (revisionId changed). Refusing to touch a human edit." + ) + + +def do_import(md_path: str, folder_id: str, title: str) -> str: + manifest = load() + existing = find_in_folder(folder_id, title) + if existing: + doc_id = existing[0]["id"] + check_unedited(doc_id, manifest) # refuses if human-edited or unmanaged + drive.files().update( + fileId=doc_id, body={"trashed": True}, supportsAllDrives=True + ).execute() + del manifest[doc_id] + + with open(md_path, "rb") as fh: + media = MediaIoBaseUpload( + io.BytesIO(fh.read()), mimetype="text/markdown", resumable=False + ) + created = ( + drive.files() + .create( + body={ + "name": title, + "mimeType": "application/vnd.google-apps.document", + "parents": [folder_id], + }, + media_body=media, + fields="id,webViewLink", + supportsAllDrives=True, + ) + .execute() + ) + manifest[created["id"]] = { + "title": title, + "folder_id": folder_id, + "revisionId": revid(created["id"]), + } + save(manifest) + print(f"created: {created['webViewLink']}") + return created["id"] + + +def trash(doc_id: str) -> None: + """Trash a managed doc, refusing if a human has edited it.""" + manifest = load() + check_unedited(doc_id, manifest) + drive.files().update( + fileId=doc_id, body={"trashed": True}, supportsAllDrives=True + ).execute() + title = manifest.pop(doc_id)["title"] + save(manifest) + print(f"trashed '{title}' ({doc_id})") + + +def seed(doc_id: str) -> None: + """Baseline an existing doc at its current revision so it may be managed + (until a human next edits it).""" + manifest = load() + f = ( + drive.files() + .get(fileId=doc_id, fields="id,name,parents", supportsAllDrives=True) + .execute() + ) + manifest[f["id"]] = { + "title": f["name"], + "folder_id": f["parents"][0], + "revisionId": revid(f["id"]), + } + save(manifest) + print(f"seeded {f['id']} '{f['name']}'") + + +def status() -> None: + manifest = load() + print(f"{len(manifest)} docs under management:") + for doc_id, rec in manifest.items(): + try: + state = "unchanged" if revid(doc_id) == rec["revisionId"] else "EDITED" + except HttpError as exc: + # Only a genuine "it isn't there" is a status. A 403, a quota error + # or a network failure means we do not know the state, and + # reporting it as MISSING/TRASHED would be a lie. + if exc.status_code != 404: + raise + state = "MISSING/TRASHED" + print(f" {rec['title']:42} {state}") + + +def main() -> int: + cmd = sys.argv[1] if len(sys.argv) > 1 else "" + if cmd == "import": + try: + do_import(sys.argv[2], sys.argv[3], sys.argv[4]) + except OverwriteRefused as e: + print(f"SAFETY NET — REFUSED: {e}") + return 3 + elif cmd == "trash": + try: + trash(sys.argv[2]) + except OverwriteRefused as e: + print(f"SAFETY NET — REFUSED: {e}") + return 3 + elif cmd == "seed": + seed(sys.argv[2]) + elif cmd == "status": + status() + else: + print(f"unknown command: {cmd!r} (use import/seed/trash/status)") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main())