mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Add SaaS AI engine (#5907)
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ indent_size = 4
|
|||||||
max_line_length = 100
|
max_line_length = 100
|
||||||
|
|
||||||
[*.py]
|
[*.py]
|
||||||
indent_size = 2
|
indent_size = 4
|
||||||
|
|
||||||
[*.gradle]
|
[*.gradle]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: AI Engine CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
engine:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: engine
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: make install
|
||||||
|
|
||||||
|
- name: Run fixers
|
||||||
|
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
|
||||||
|
run: make fix || true
|
||||||
|
|
||||||
|
- name: Check for fixer changes
|
||||||
|
id: fixer_changes
|
||||||
|
run: |
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Post fixer suggestions
|
||||||
|
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||||
|
uses: reviewdog/action-suggester@v1
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
tool_name: engine-make-fix
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
filter_mode: file
|
||||||
|
fail_level: any
|
||||||
|
level: info
|
||||||
|
|
||||||
|
- name: Comment on fixer suggestions
|
||||||
|
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Verify fixer changes are committed
|
||||||
|
if: steps.fixer_changes.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
if ! git diff --exit-code; then
|
||||||
|
echo "Fixes are out of date."
|
||||||
|
echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
|
||||||
|
git --no-pager diff --stat
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: make lint
|
||||||
|
|
||||||
|
- name: Run type checking
|
||||||
|
run: make typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make test
|
||||||
@@ -19,6 +19,18 @@ This file provides guidance to AI Agents when working with code in this reposito
|
|||||||
### Security Mode Development
|
### Security Mode Development
|
||||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||||
|
|
||||||
|
### Python Development
|
||||||
|
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
|
||||||
|
|
||||||
|
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
|
||||||
|
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
|
||||||
|
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
|
||||||
|
- Use Makefile commands for Python work:
|
||||||
|
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
|
||||||
|
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
|
||||||
|
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
|
||||||
|
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||||
@@ -82,7 +94,7 @@ export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
```tsx
|
||||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||||
@@ -100,7 +112,7 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
```tsx
|
||||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Portions of this software are licensed as follows:
|
|||||||
|
|
||||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||||
|
* All content that resides under the "engine/" directory of this repository,
|
||||||
|
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# LaTeX outputs
|
||||||
|
*.aux
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.toc
|
||||||
|
*.pdf
|
||||||
|
*.tex
|
||||||
|
!src/default_templates/*.tex
|
||||||
|
data/
|
||||||
|
output/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13.8
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# syntax=docker/dockerfile:1.5
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||||
|
|
||||||
|
# Install system deps: Node.js + Puppeteer/Chromium runtime libraries
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
|
apt-get update && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
poppler-utils \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
# Fonts for correct text rendering in generated PDFs
|
||||||
|
fonts-liberation \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
# Chromium headless runtime deps
|
||||||
|
libnss3 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxrandr2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libasound2 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libgbm1 \
|
||||||
|
libdrm2 \
|
||||||
|
libxshmfence1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Node dependencies (Puppeteer) first for layer caching
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
# Copy Python source into /app/src/
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Create output directories
|
||||||
|
RUN mkdir -p /app/output /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5001
|
||||||
|
|
||||||
|
# Environment variables for Gunicorn configuration
|
||||||
|
ENV GUNICORN_WORKERS=4
|
||||||
|
ENV GUNICORN_TIMEOUT=120
|
||||||
|
# Disable Python stdout/stderr buffering so log output appears immediately in Docker logs
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
WORKDIR /app/src
|
||||||
|
CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:5001 --workers ${GUNICORN_WORKERS} --timeout ${GUNICORN_TIMEOUT} --worker-class gthread --threads 4 app:app"]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# syntax=docker/dockerfile:1.5
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||||
|
|
||||||
|
# Install system deps: Node.js + Puppeteer/Chromium runtime libraries
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
|
apt-get update && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
poppler-utils \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
# Fonts for correct text rendering in generated PDFs
|
||||||
|
fonts-liberation \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
# Chromium headless runtime deps
|
||||||
|
libnss3 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxrandr2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libasound2 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Node dependencies (Puppeteer) first for layer caching
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy only pyproject.toml and uv.lock first for dependency caching
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
# Install dependencies including dev extras
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen
|
||||||
|
|
||||||
|
# Create output directories
|
||||||
|
RUN mkdir -p /app/output /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5001
|
||||||
|
|
||||||
|
# Set environment for Flask development
|
||||||
|
ENV FLASK_ENV=development
|
||||||
|
ENV FLASK_DEBUG=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Run the Flask app with hot reload
|
||||||
|
# Note: src/ will be mounted as a volume at runtime
|
||||||
|
WORKDIR /app/src
|
||||||
|
CMD ["uv", "run", "python", "app.py"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
Stirling PDF User License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Stirling PDF Inc.
|
||||||
|
|
||||||
|
License Scope & Usage Rights
|
||||||
|
|
||||||
|
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||||
|
|
||||||
|
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||||
|
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||||
|
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||||
|
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||||
|
covering the appropriate number of licensed users.
|
||||||
|
|
||||||
|
Trial and Minimal Use
|
||||||
|
|
||||||
|
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||||
|
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||||
|
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||||
|
|
||||||
|
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||||
|
|
||||||
|
Modifications and Derivative Works
|
||||||
|
|
||||||
|
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||||
|
|
||||||
|
* May not be deployed in production environments without a valid User License;
|
||||||
|
* May not be distributed or sublicensed;
|
||||||
|
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||||
|
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||||
|
|
||||||
|
Prohibited Actions
|
||||||
|
|
||||||
|
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||||
|
|
||||||
|
* Use the Software in production environments;
|
||||||
|
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||||
|
* Remove or alter any licensing or copyright notices;
|
||||||
|
* Circumvent access restrictions or licensing requirements.
|
||||||
|
|
||||||
|
Third-Party Components
|
||||||
|
|
||||||
|
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||||
|
their original license terms as provided by their respective owners.
|
||||||
|
|
||||||
|
Disclaimer
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.PHONY: help install prep check fix lint lint-fix lint-fix-unsafe format format-check typecheck test run run-dev clean tool-models docker-build docker-run
|
||||||
|
|
||||||
|
REPO_ROOT_DIR = ..
|
||||||
|
ROOT_DIR = .
|
||||||
|
VENV_DIR = $(ROOT_DIR)/.venv
|
||||||
|
DEPS_STAMP := $(VENV_DIR)/.deps-installed
|
||||||
|
NODE_DEPS_STAMP := node_modules/.deps-installed
|
||||||
|
TOOL_MODELS := $(CURDIR)/src/models/tool_models.py
|
||||||
|
FRONTEND_DIR := $(REPO_ROOT_DIR)/frontend
|
||||||
|
FRONTEND_TSX := $(FRONTEND_DIR)/node_modules/.bin/tsx
|
||||||
|
|
||||||
|
$(DEPS_STAMP): $(ROOT_DIR)/uv.lock $(ROOT_DIR)/pyproject.toml
|
||||||
|
uv python install 3.13.8
|
||||||
|
uv sync
|
||||||
|
touch $(DEPS_STAMP)
|
||||||
|
|
||||||
|
$(NODE_DEPS_STAMP): package-lock.json
|
||||||
|
npm ci
|
||||||
|
touch $(NODE_DEPS_STAMP)
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Engine commands:"
|
||||||
|
@echo " make install - Install production dependencies"
|
||||||
|
@echo " make prep - Set up .env file from .env.example"
|
||||||
|
@echo " make lint - Run linting checks"
|
||||||
|
@echo " make format - Format code"
|
||||||
|
@echo " make format-check - Show whether code is correctly formatted"
|
||||||
|
@echo " make typecheck - Run type checking"
|
||||||
|
@echo " make test - Run tests"
|
||||||
|
@echo " make run - Run the Flask backend with gunicorn (production)"
|
||||||
|
@echo " make run-dev - Run the Flask backend with dev server (development only)"
|
||||||
|
@echo " make tool-models - Generate src/models/tool_models_*.py from frontend TypeScript tool defs"
|
||||||
|
@echo " make clean - Clean up generated files"
|
||||||
|
@echo " make docker-build - Build Docker image"
|
||||||
|
@echo " make docker-run - Run Docker container"
|
||||||
|
|
||||||
|
install: $(DEPS_STAMP) $(NODE_DEPS_STAMP)
|
||||||
|
|
||||||
|
prep: install
|
||||||
|
uv run scripts/setup_env.py
|
||||||
|
|
||||||
|
check: typecheck lint format-check test
|
||||||
|
|
||||||
|
fix: lint-fix format
|
||||||
|
|
||||||
|
lint: install
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
lint-fix: install
|
||||||
|
uv run ruff check . --fix
|
||||||
|
|
||||||
|
lint-fix-unsafe: install
|
||||||
|
uv run ruff check . --fix --unsafe-fixes
|
||||||
|
|
||||||
|
format: install
|
||||||
|
uv run ruff format .
|
||||||
|
|
||||||
|
format-check: install
|
||||||
|
uv run ruff format . --diff
|
||||||
|
|
||||||
|
typecheck: install
|
||||||
|
uv run pyright . --warnings
|
||||||
|
|
||||||
|
test: prep
|
||||||
|
uv run pytest tests
|
||||||
|
|
||||||
|
run: prep
|
||||||
|
cd src && PYTHONUNBUFFERED=1 uv run gunicorn --bind 0.0.0.0:5001 --workers 4 --timeout 120 --worker-class gthread --threads 4 app:app
|
||||||
|
|
||||||
|
run-dev: prep
|
||||||
|
cd src && PYTHONUNBUFFERED=1 uv run python app.py
|
||||||
|
|
||||||
|
frontend-deps:
|
||||||
|
if [ ! -x "$(FRONTEND_TSX)" ]; then cd $(FRONTEND_DIR) && npm install; fi
|
||||||
|
|
||||||
|
tool-models: install frontend-deps
|
||||||
|
uv run python scripts/generate_tool_models.py --output $(TOOL_MODELS)
|
||||||
|
$(MAKE) fix
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(VENV_DIR) data logs output
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t stirling-pdf-engine .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run -p 5001:5001 stirling-pdf-engine
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Claude API (Anthropic)
|
||||||
|
STIRLING_ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# OpenAI API (Optional - for GPT models)
|
||||||
|
STIRLING_OPENAI_API_KEY=
|
||||||
|
STIRLING_OPENAI_BASE_URL=
|
||||||
|
|
||||||
|
# Model Configuration
|
||||||
|
STIRLING_SMART_MODEL=claude-sonnet-4-5-20250929
|
||||||
|
STIRLING_FAST_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|
||||||
|
# GPT-5 Reasoning Settings (only used if SMART_MODEL is gpt-5)
|
||||||
|
# Options: minimal, low, medium, high, xhigh
|
||||||
|
STIRLING_SMART_MODEL_REASONING_EFFORT=medium
|
||||||
|
STIRLING_FAST_MODEL_REASONING_EFFORT=minimal
|
||||||
|
|
||||||
|
# GPT-5 Text Verbosity (only used if SMART_MODEL is gpt-5)
|
||||||
|
# Options: minimal, low, medium, high
|
||||||
|
STIRLING_SMART_MODEL_TEXT_VERBOSITY=medium
|
||||||
|
STIRLING_FAST_MODEL_TEXT_VERBOSITY=low
|
||||||
|
|
||||||
|
# Output token limits. STIRLING_AI_MAX_TOKENS is a global override (leave empty to use per-model defaults)
|
||||||
|
STIRLING_AI_MAX_TOKENS=
|
||||||
|
STIRLING_SMART_MODEL_MAX_TOKENS=8192
|
||||||
|
STIRLING_FAST_MODEL_MAX_TOKENS=2048
|
||||||
|
STIRLING_CLAUDE_MAX_TOKENS=4096
|
||||||
|
STIRLING_DEFAULT_MODEL_MAX_TOKENS=4096
|
||||||
|
|
||||||
|
# PostHog Analytics
|
||||||
|
STIRLING_POSTHOG_API_KEY=
|
||||||
|
STIRLING_POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
|
||||||
|
# Backend Configuration
|
||||||
|
STIRLING_JAVA_BACKEND_URL=http://localhost:8080
|
||||||
|
STIRLING_JAVA_BACKEND_API_KEY=123456789
|
||||||
|
STIRLING_JAVA_REQUEST_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# Logging and Debugging
|
||||||
|
STIRLING_AI_RAW_DEBUG=0
|
||||||
|
STIRLING_FLASK_DEBUG=0
|
||||||
|
# Override the log file directory (defaults to ./logs in server mode, OS log dir in desktop mode)
|
||||||
|
STIRLING_LOG_PATH=
|
||||||
|
# Enable verbose table detection debug logging in the PDF text editor
|
||||||
|
STIRLING_PDF_EDITOR_TABLE_DEBUG=0
|
||||||
|
# Set to true when running as a Tauri desktop app (switches to OS-native log directory)
|
||||||
|
STIRLING_PDF_TAURI_MODE=false
|
||||||
|
|
||||||
|
# AI Settings
|
||||||
|
STIRLING_AI_STREAMING=true
|
||||||
|
STIRLING_AI_PREVIEW_MAX_INFLIGHT=3
|
||||||
|
STIRLING_AI_REQUEST_TIMEOUT=70
|
||||||
Generated
+1138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "stirling-docgen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "Node tooling for Stirling PDF docgen backend (HTML→PDF via Puppeteer)",
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "node -e \"require('puppeteer')\" 2>/dev/null || true"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer": "^24.25.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
[project]
|
||||||
|
name = "engine"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI Document Engine"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.0.0",
|
||||||
|
"flask-cors>=4.0.0",
|
||||||
|
"gunicorn>=25.0.3",
|
||||||
|
"openai>=2.20.0",
|
||||||
|
"langchain-core>=1.2.11",
|
||||||
|
"langchain-openai>=1.1.9",
|
||||||
|
"langchain-anthropic>=1.1.9",
|
||||||
|
"platformdirs>=4.5.1",
|
||||||
|
"pypdf>=6.7.0",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"posthog>=7.8.6",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"beautifulsoup4>=4.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"ruff>=0.14.10",
|
||||||
|
"pyright>=1.1.408",
|
||||||
|
"datamodel-code-generator[ruff]>=0.27.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
exclude = [
|
||||||
|
"tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"N",
|
||||||
|
"W",
|
||||||
|
"RUF100",
|
||||||
|
"UP",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # Temporarily disable line length limit until codebase conformat
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
pythonVersion = "3.13"
|
||||||
|
|
||||||
|
reportImportCycles = "warning"
|
||||||
|
reportUnnecessaryCast = "warning"
|
||||||
|
reportUnusedImport = "warning"
|
||||||
|
#reportUnknownParameterType = "warning"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import keyword
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
TOOL_MODELS_HEADER = """# AUTO-GENERATED FILE. DO NOT EDIT.
|
||||||
|
# Generated by scripts/generate_tool_models.py from frontend TypeScript sources.
|
||||||
|
# ruff: noqa: N815
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
OPERATION_TYPE_RE = re.compile(r"operationType\s*:\s*['\"]([A-Za-z0-9_]+)['\"]")
|
||||||
|
DEFAULT_REF_RE = re.compile(r"defaultParameters\s*:\s*([A-Za-z0-9_]+)")
|
||||||
|
DEFAULT_SHORTHAND_RE = re.compile(r"\bdefaultParameters\b")
|
||||||
|
IMPORT_RE = re.compile(r"import\s*\{([^}]+)\}\s*from\s*['\"]([^'\"]+)['\"]")
|
||||||
|
VAR_OBJ_RE_TEMPLATE = r"(?:export\s+)?const\s+{name}\b[^=]*=\s*\{{"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolModelSpec:
|
||||||
|
tool_id: str
|
||||||
|
params: dict[str, Any]
|
||||||
|
param_types: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _find_matching(text: str, start: int, open_char: str, close_char: str) -> int:
|
||||||
|
depth = 0
|
||||||
|
i = start
|
||||||
|
in_str: str | None = None
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if in_str:
|
||||||
|
if ch == "\\":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if ch == in_str:
|
||||||
|
in_str = None
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch in {"'", '"'}:
|
||||||
|
in_str = ch
|
||||||
|
elif ch == open_char:
|
||||||
|
depth += 1
|
||||||
|
elif ch == close_char:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
i += 1
|
||||||
|
raise ParseError(f"Unmatched {open_char}{close_char} block")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_block(text: str, pattern: str) -> str | None:
|
||||||
|
match = re.search(pattern, text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
brace_start = text.find("{", match.end() - 1)
|
||||||
|
if brace_start == -1:
|
||||||
|
return None
|
||||||
|
brace_end = _find_matching(text, brace_start, "{", "}")
|
||||||
|
return text[brace_start : brace_end + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_top_level_items(obj_body: str) -> list[str]:
|
||||||
|
items: list[str] = []
|
||||||
|
depth_obj = depth_arr = 0
|
||||||
|
in_str: str | None = None
|
||||||
|
token_start = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(obj_body):
|
||||||
|
ch = obj_body[i]
|
||||||
|
if in_str:
|
||||||
|
if ch == "\\":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if ch == in_str:
|
||||||
|
in_str = None
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch in {"'", '"'}:
|
||||||
|
in_str = ch
|
||||||
|
elif ch == "{":
|
||||||
|
depth_obj += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth_obj -= 1
|
||||||
|
elif ch == "[":
|
||||||
|
depth_arr += 1
|
||||||
|
elif ch == "]":
|
||||||
|
depth_arr -= 1
|
||||||
|
elif ch == "," and depth_obj == 0 and depth_arr == 0:
|
||||||
|
piece = obj_body[token_start:i].strip()
|
||||||
|
if piece:
|
||||||
|
items.append(piece)
|
||||||
|
token_start = i + 1
|
||||||
|
i += 1
|
||||||
|
tail = obj_body[token_start:].strip()
|
||||||
|
if tail:
|
||||||
|
items.append(tail)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_import_path(repo_root: Path, current_file: Path, module_path: str) -> Path | None:
|
||||||
|
candidates: list[Path] = []
|
||||||
|
if module_path.startswith("@app/"):
|
||||||
|
rel = module_path[len("@app/") :]
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
repo_root / "frontend/src/core" / f"{rel}.ts",
|
||||||
|
repo_root / "frontend/src/core" / f"{rel}.tsx",
|
||||||
|
repo_root / "frontend/src/saas" / f"{rel}.ts",
|
||||||
|
repo_root / "frontend/src/saas" / f"{rel}.tsx",
|
||||||
|
repo_root / "frontend/src" / f"{rel}.ts",
|
||||||
|
repo_root / "frontend/src" / f"{rel}.tsx",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif module_path.startswith("."):
|
||||||
|
base = (current_file.parent / module_path).resolve()
|
||||||
|
candidates.extend([Path(f"{base}.ts"), Path(f"{base}.tsx")])
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_literal_value(value: str, resolver: Callable[[str], dict[str, Any] | None]) -> Any:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if value.startswith("{") and value.endswith("}"):
|
||||||
|
return _parse_object_literal(value, resolver)
|
||||||
|
if value.startswith("[") and value.endswith("]"):
|
||||||
|
inner = value[1:-1].strip()
|
||||||
|
if not inner:
|
||||||
|
return []
|
||||||
|
return [_parse_literal_value(item, resolver) for item in _split_top_level_items(inner)]
|
||||||
|
if value.startswith(("'", '"')) and value.endswith(("'", '"')):
|
||||||
|
return value[1:-1]
|
||||||
|
if value in {"true", "false"}:
|
||||||
|
return value == "true"
|
||||||
|
if value == "null":
|
||||||
|
return None
|
||||||
|
if re.fullmatch(r"-?\d+", value):
|
||||||
|
return int(value)
|
||||||
|
if re.fullmatch(r"-?\d+\.\d+", value):
|
||||||
|
return float(value)
|
||||||
|
resolved = resolver(value)
|
||||||
|
if resolved is not None:
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_object_literal(obj_text: str, resolver: Callable[[str], dict[str, Any] | None]) -> dict[str, Any]:
|
||||||
|
body = obj_text.strip()[1:-1]
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for item in _split_top_level_items(body):
|
||||||
|
if item.startswith("..."):
|
||||||
|
spread_name = item[3:].strip()
|
||||||
|
spread = resolver(spread_name)
|
||||||
|
if isinstance(spread, dict):
|
||||||
|
result.update(spread)
|
||||||
|
continue
|
||||||
|
if ":" not in item:
|
||||||
|
continue
|
||||||
|
key, raw_value = item.split(":", 1)
|
||||||
|
key = key.strip().strip("'\"")
|
||||||
|
result[key] = _parse_literal_value(raw_value.strip(), resolver)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_imports(source: str) -> dict[str, str]:
|
||||||
|
imports: dict[str, str] = {}
|
||||||
|
for names, module_path in IMPORT_RE.findall(source):
|
||||||
|
for part in names.split(","):
|
||||||
|
segment = part.strip()
|
||||||
|
if not segment:
|
||||||
|
continue
|
||||||
|
if " as " in segment:
|
||||||
|
original, alias = [x.strip() for x in segment.split(" as ", 1)]
|
||||||
|
imports[alias] = module_path
|
||||||
|
imports[original] = module_path
|
||||||
|
else:
|
||||||
|
imports[segment] = module_path
|
||||||
|
return imports
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_object_identifier(repo_root: Path, file_path: Path, source: str, identifier: str) -> dict[str, Any] | None:
|
||||||
|
var_pattern = VAR_OBJ_RE_TEMPLATE.format(name=re.escape(identifier))
|
||||||
|
block = _extract_block(source, var_pattern)
|
||||||
|
imports = _extract_imports(source)
|
||||||
|
|
||||||
|
def resolver(name: str) -> dict[str, Any] | None:
|
||||||
|
local_block = _extract_block(source, VAR_OBJ_RE_TEMPLATE.format(name=re.escape(name)))
|
||||||
|
if local_block:
|
||||||
|
return _parse_object_literal(local_block, resolver)
|
||||||
|
import_path = imports.get(name)
|
||||||
|
if not import_path:
|
||||||
|
return None
|
||||||
|
resolved_file = _resolve_import_path(repo_root, file_path, import_path)
|
||||||
|
if not resolved_file:
|
||||||
|
return None
|
||||||
|
imported_source = resolved_file.read_text(encoding="utf-8")
|
||||||
|
return _resolve_object_identifier(repo_root, resolved_file, imported_source, name)
|
||||||
|
|
||||||
|
if block:
|
||||||
|
return _parse_object_literal(block, resolver)
|
||||||
|
import_path = imports.get(identifier)
|
||||||
|
if not import_path:
|
||||||
|
return None
|
||||||
|
resolved_file = _resolve_import_path(repo_root, file_path, import_path)
|
||||||
|
if not resolved_file:
|
||||||
|
return None
|
||||||
|
imported_source = resolved_file.read_text(encoding="utf-8")
|
||||||
|
return _resolve_object_identifier(repo_root, resolved_file, imported_source, identifier)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_py_type(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "bool"
|
||||||
|
if isinstance(value, int):
|
||||||
|
return "int"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return "float"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return "str"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "list[Any]"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return "dict[str, Any]"
|
||||||
|
return "Any"
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_is_none(spec: dict[str, Any]) -> bool:
|
||||||
|
return spec.get("kind") == "null"
|
||||||
|
|
||||||
|
|
||||||
|
def _py_type_from_spec(spec: dict[str, Any]) -> str:
|
||||||
|
kind = spec.get("kind")
|
||||||
|
if kind == "string":
|
||||||
|
return "str"
|
||||||
|
if kind == "number":
|
||||||
|
return "float"
|
||||||
|
if kind == "boolean":
|
||||||
|
return "bool"
|
||||||
|
if kind == "date":
|
||||||
|
return "str"
|
||||||
|
if kind == "enum":
|
||||||
|
values = spec.get("values")
|
||||||
|
if isinstance(values, list) and values:
|
||||||
|
literal_values = ", ".join(_py_repr(v) for v in values)
|
||||||
|
return f"Literal[{literal_values}]"
|
||||||
|
if kind == "ref":
|
||||||
|
ref_name = spec.get("name")
|
||||||
|
if isinstance(ref_name, str) and ref_name.endswith("Parameters"):
|
||||||
|
return f"{ref_name[:-10]}Params"
|
||||||
|
if kind == "array":
|
||||||
|
element = spec.get("element")
|
||||||
|
inner = _py_type_from_spec(element) if isinstance(element, dict) else "Any"
|
||||||
|
return f"list[{inner}]"
|
||||||
|
if kind == "object":
|
||||||
|
dict_value = spec.get("dictValue")
|
||||||
|
if isinstance(dict_value, dict):
|
||||||
|
inner = _py_type_from_spec(dict_value)
|
||||||
|
return f"dict[str, {inner}]"
|
||||||
|
properties = spec.get("properties")
|
||||||
|
if isinstance(properties, dict) and properties:
|
||||||
|
property_types = {_py_type_from_spec(p) for p in properties.values() if isinstance(p, dict)}
|
||||||
|
if len(property_types) == 1:
|
||||||
|
inner = next(iter(property_types))
|
||||||
|
return f"dict[str, {inner}]"
|
||||||
|
return "dict[str, Any]"
|
||||||
|
if kind in {"null"}:
|
||||||
|
return "Any"
|
||||||
|
return "Any"
|
||||||
|
|
||||||
|
|
||||||
|
def _to_class_name(tool_id: str) -> str:
|
||||||
|
cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", tool_id)
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9]+", " ", cleaned)
|
||||||
|
parts = [part.capitalize() for part in cleaned.split() if part]
|
||||||
|
return "".join(parts) + "Params"
|
||||||
|
|
||||||
|
|
||||||
|
def _to_snake_case(name: str) -> str:
|
||||||
|
snake = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||||
|
snake = re.sub(r"[^A-Za-z0-9]+", "_", snake).strip("_").lower()
|
||||||
|
if not snake:
|
||||||
|
snake = "param"
|
||||||
|
if snake[0].isdigit():
|
||||||
|
snake = f"param_{snake}"
|
||||||
|
if keyword.iskeyword(snake):
|
||||||
|
snake = f"{snake}_"
|
||||||
|
return snake
|
||||||
|
|
||||||
|
|
||||||
|
def _build_field_name_map(params: dict[str, Any]) -> dict[str, str]:
|
||||||
|
field_map: dict[str, str] = {}
|
||||||
|
used: set[str] = set()
|
||||||
|
for original_key in sorted(params):
|
||||||
|
base_name = _to_snake_case(original_key)
|
||||||
|
candidate = base_name
|
||||||
|
suffix = 2
|
||||||
|
while candidate in used:
|
||||||
|
candidate = f"{base_name}_{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
used.add(candidate)
|
||||||
|
field_map[original_key] = candidate
|
||||||
|
return field_map
|
||||||
|
|
||||||
|
|
||||||
|
def _to_enum_member_name(tool_id: str) -> str:
|
||||||
|
return _to_snake_case(tool_id).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_enum_member_map(specs: list[ToolModelSpec]) -> dict[str, str]:
|
||||||
|
member_map: dict[str, str] = {}
|
||||||
|
used: set[str] = set()
|
||||||
|
for spec in specs:
|
||||||
|
base_name = _to_enum_member_name(spec.tool_id)
|
||||||
|
candidate = base_name
|
||||||
|
suffix = 2
|
||||||
|
while candidate in used:
|
||||||
|
candidate = f"{base_name}_{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
used.add(candidate)
|
||||||
|
member_map[spec.tool_id] = candidate
|
||||||
|
return member_map
|
||||||
|
|
||||||
|
|
||||||
|
def _py_repr(value: Any) -> str:
|
||||||
|
return (
|
||||||
|
json.dumps(value, ensure_ascii=True).replace("true", "True").replace("false", "False").replace("null", "None")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_tool_specs(repo_root: Path) -> list[ToolModelSpec]:
|
||||||
|
frontend_dir = repo_root / "frontend"
|
||||||
|
extractor = frontend_dir / "scripts/export-tool-specs.ts"
|
||||||
|
command = ["node", "--import", "tsx", str(extractor)]
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(frontend_dir),
|
||||||
|
)
|
||||||
|
raw = json.loads(result.stdout)
|
||||||
|
specs: list[ToolModelSpec] = []
|
||||||
|
for item in raw:
|
||||||
|
tool_id = item.get("tool_id")
|
||||||
|
if not isinstance(tool_id, str) or not tool_id:
|
||||||
|
continue
|
||||||
|
params = item.get("params")
|
||||||
|
param_types = item.get("param_types")
|
||||||
|
specs.append(
|
||||||
|
ToolModelSpec(
|
||||||
|
tool_id=tool_id,
|
||||||
|
params=params if isinstance(params, dict) else {},
|
||||||
|
param_types=param_types if isinstance(param_types, dict) else {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(specs, key=lambda spec: spec.tool_id)
|
||||||
|
|
||||||
|
|
||||||
|
def write_models_module(out_path: Path, specs: list[ToolModelSpec]) -> None:
|
||||||
|
lines: list[str] = [
|
||||||
|
TOOL_MODELS_HEADER,
|
||||||
|
"from __future__ import annotations\n\n",
|
||||||
|
"from enum import StrEnum\n",
|
||||||
|
"from typing import Any, Literal\n\n",
|
||||||
|
"from models.base import ApiModel\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
class_names: dict[str, str] = {spec.tool_id: _to_class_name(spec.tool_id) for spec in specs}
|
||||||
|
class_name_to_tool_id = {name: tool_id for tool_id, name in class_names.items()}
|
||||||
|
|
||||||
|
def extract_class_dependencies(spec: ToolModelSpec) -> set[str]:
|
||||||
|
deps: set[str] = set()
|
||||||
|
if not isinstance(spec.param_types, dict):
|
||||||
|
return deps
|
||||||
|
for entry in spec.param_types.values():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
type_spec = entry
|
||||||
|
if "type" in entry and isinstance(entry.get("type"), dict):
|
||||||
|
type_spec = entry["type"]
|
||||||
|
if not isinstance(type_spec, dict):
|
||||||
|
continue
|
||||||
|
if type_spec.get("kind") != "ref":
|
||||||
|
continue
|
||||||
|
ref_name = type_spec.get("name")
|
||||||
|
if isinstance(ref_name, str) and ref_name.endswith("Parameters"):
|
||||||
|
ref_class = f"{ref_name[:-10]}Params"
|
||||||
|
if ref_class in class_name_to_tool_id:
|
||||||
|
deps.add(ref_class)
|
||||||
|
return deps
|
||||||
|
|
||||||
|
dependencies_by_class: dict[str, set[str]] = {}
|
||||||
|
for spec in specs:
|
||||||
|
class_name = class_names[spec.tool_id]
|
||||||
|
dependencies_by_class[class_name] = extract_class_dependencies(spec)
|
||||||
|
|
||||||
|
remaining = set(class_names.values())
|
||||||
|
ordered_class_names: list[str] = []
|
||||||
|
while remaining:
|
||||||
|
progress = False
|
||||||
|
for class_name in sorted(remaining):
|
||||||
|
deps = dependencies_by_class.get(class_name, set())
|
||||||
|
if deps.issubset(set(ordered_class_names)):
|
||||||
|
ordered_class_names.append(class_name)
|
||||||
|
remaining.remove(class_name)
|
||||||
|
progress = True
|
||||||
|
break
|
||||||
|
if not progress:
|
||||||
|
ordered_class_names.extend(sorted(remaining))
|
||||||
|
break
|
||||||
|
|
||||||
|
ordered_specs = [next(spec for spec in specs if class_names[spec.tool_id] == name) for name in ordered_class_names]
|
||||||
|
|
||||||
|
for spec in ordered_specs:
|
||||||
|
class_name = class_names[spec.tool_id]
|
||||||
|
lines.append(f"class {class_name}(ApiModel):\n")
|
||||||
|
all_param_keys = set(spec.params)
|
||||||
|
if isinstance(spec.param_types, dict):
|
||||||
|
all_param_keys.update(spec.param_types.keys())
|
||||||
|
|
||||||
|
if not all_param_keys:
|
||||||
|
lines.append(" pass\n\n\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
field_name_map = _build_field_name_map({key: True for key in all_param_keys})
|
||||||
|
for key in sorted(all_param_keys):
|
||||||
|
field_name = field_name_map[key]
|
||||||
|
value = spec.params.get(key)
|
||||||
|
type_spec = spec.param_types.get(key) if isinstance(spec.param_types, dict) else None
|
||||||
|
if isinstance(type_spec, dict):
|
||||||
|
py_type = _py_type_from_spec(type_spec)
|
||||||
|
else:
|
||||||
|
py_type = _infer_py_type(value)
|
||||||
|
|
||||||
|
if value is None and (isinstance(type_spec, dict) and _spec_is_none(type_spec)):
|
||||||
|
if py_type != "Any" and "| None" not in py_type:
|
||||||
|
py_type = f"{py_type} | None"
|
||||||
|
lines.append(f" {field_name}: {py_type} = None\n")
|
||||||
|
elif value is None:
|
||||||
|
lines.append(f" {field_name}: {py_type} | None = None\n")
|
||||||
|
else:
|
||||||
|
if isinstance(type_spec, dict) and type_spec.get("kind") == "ref" and isinstance(value, dict):
|
||||||
|
lines.append(f" {field_name}: {py_type} = {py_type}.model_validate({_py_repr(value)})\n")
|
||||||
|
continue
|
||||||
|
lines.append(f" {field_name}: {py_type} = {_py_repr(value)}\n")
|
||||||
|
lines.append("\n\n")
|
||||||
|
|
||||||
|
if class_names:
|
||||||
|
union_members = " | ".join(class_names[tool_id] for tool_id in sorted(class_names))
|
||||||
|
lines.append(f"type ParamToolModel = {union_members}\n")
|
||||||
|
lines.append("type ParamToolModelType = type[ParamToolModel]\n\n")
|
||||||
|
else:
|
||||||
|
lines.append("type ParamToolModel = ApiModel\n")
|
||||||
|
lines.append("type ParamToolModelType = type[ParamToolModel]\n\n")
|
||||||
|
|
||||||
|
enum_member_map = _build_enum_member_map(specs)
|
||||||
|
|
||||||
|
lines.append("class OperationId(StrEnum):\n")
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
lines.append(f" {enum_member_map[spec.tool_id]} = {spec.tool_id!r}\n")
|
||||||
|
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"\n\n",
|
||||||
|
"OPERATIONS: dict[OperationId, ParamToolModelType] = {\n",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
model_name = _to_class_name(spec.tool_id)
|
||||||
|
lines.append(f" OperationId.{enum_member_map[spec.tool_id]}: {model_name},\n")
|
||||||
|
lines.append("}\n")
|
||||||
|
out_path.write_text("".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate tool models from frontend TypeScript tool definitions")
|
||||||
|
parser.add_argument("--spec", help="Deprecated (ignored)", default="")
|
||||||
|
parser.add_argument("--output", default="", help="Path to tool_models.py")
|
||||||
|
parser.add_argument("--ai-output", default="", help="Deprecated (ignored)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[3]
|
||||||
|
specs = discover_tool_specs(repo_root)
|
||||||
|
|
||||||
|
output_path = Path(args.output) if args.output else (repo_root / "docgen/backend/models/tool_models.py")
|
||||||
|
|
||||||
|
write_models_module(output_path, specs)
|
||||||
|
print(f"Wrote {len(specs)} tool model specs")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Copies .env from .env.example if missing, and errors if any keys from the example
|
||||||
|
are absent from the actual .env file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run scripts/setup_env.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent.parent
|
||||||
|
EXAMPLE_FILE = ROOT / "config" / ".env.example"
|
||||||
|
ENV_FILE = ROOT / ".env"
|
||||||
|
|
||||||
|
print("setup-env: see engine/config/.env.example for documentation")
|
||||||
|
|
||||||
|
if not EXAMPLE_FILE.exists():
|
||||||
|
print(f"setup-env: {EXAMPLE_FILE.name} not found, skipping", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
shutil.copy(EXAMPLE_FILE, ENV_FILE)
|
||||||
|
print("setup-env: created .env from .env.example")
|
||||||
|
|
||||||
|
env_keys = set(dotenv_values(ENV_FILE).keys()) | set(os.environ.keys())
|
||||||
|
example_keys = set(dotenv_values(EXAMPLE_FILE).keys())
|
||||||
|
missing = sorted(example_keys - env_keys)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
sys.exit(
|
||||||
|
"setup-env: .env is missing keys from .env.example:\n"
|
||||||
|
+ "\n".join(f" {k}" for k in missing)
|
||||||
|
+ "\n Add them manually or delete your local .env to re-copy from config/.env.example."
|
||||||
|
)
|
||||||
|
|
||||||
|
extra = sorted(k for k in dotenv_values(ENV_FILE) if k.startswith("STIRLING_") and k not in example_keys)
|
||||||
|
if extra:
|
||||||
|
print(
|
||||||
|
"setup-env: .env contains STIRLING_ keys not in config/.env.example:\n"
|
||||||
|
+ "\n".join(f" {k}" for k in extra)
|
||||||
|
+ "\n Add them to config/.env.example if they are intentional.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import models
|
||||||
|
from config import SMART_MODEL, STREAMING_ENABLED
|
||||||
|
from format_prompts import get_format_prompt
|
||||||
|
from html_utils import inject_theme
|
||||||
|
from llm_utils import run_ai, stream_ai
|
||||||
|
from prompts import (
|
||||||
|
field_values_system_prompt,
|
||||||
|
html_context_messages,
|
||||||
|
html_edit_system_prompt,
|
||||||
|
html_system_prompt,
|
||||||
|
outline_generator_system_prompt,
|
||||||
|
section_draft_system_prompt,
|
||||||
|
template_fill_html_system_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_outline_with_llm(
|
||||||
|
prompt: str,
|
||||||
|
document_type: str,
|
||||||
|
constraints: models.Constraint | None = None,
|
||||||
|
) -> models.OutlineResponse:
|
||||||
|
"""
|
||||||
|
Generate outline for a specific document type.
|
||||||
|
|
||||||
|
document_type should already be detected - this function only generates the outline.
|
||||||
|
"""
|
||||||
|
constraint_text = ""
|
||||||
|
if constraints:
|
||||||
|
tone = constraints.tone
|
||||||
|
audience = constraints.audience
|
||||||
|
pages = constraints.page_count
|
||||||
|
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||||
|
|
||||||
|
# Try to get a format-specific extraction prompt
|
||||||
|
format_prompt, default_sections = get_format_prompt(document_type)
|
||||||
|
system_prompt = outline_generator_system_prompt(document_type, constraint_text, format_prompt, default_sections)
|
||||||
|
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=prompt),
|
||||||
|
]
|
||||||
|
|
||||||
|
parsed = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.OutlineResponse,
|
||||||
|
tag="outline",
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed:
|
||||||
|
logger.info(
|
||||||
|
"[OUTLINE] Generated outline doc_type=%s sections=%d filename=%s",
|
||||||
|
parsed.doc_type,
|
||||||
|
len(parsed.sections),
|
||||||
|
parsed.outline_filename,
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
raise RuntimeError("AI outline generation failed.")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_field_values(
|
||||||
|
prompt: str,
|
||||||
|
document_type: str,
|
||||||
|
fields: list[dict[str, Any]],
|
||||||
|
constraints: models.Constraint | None = None,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
constraint_text = ""
|
||||||
|
if constraints:
|
||||||
|
tone = constraints.tone
|
||||||
|
audience = constraints.audience
|
||||||
|
pages = constraints.page_count
|
||||||
|
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||||
|
system_prompt = field_values_system_prompt(constraint_text)
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=f"Document type: {document_type}"),
|
||||||
|
models.ChatMessage(role="user", content=f"Prompt:\n{prompt}"),
|
||||||
|
models.ChatMessage(role="user", content=f"Fields:\n{json.dumps(fields, ensure_ascii=True)}"),
|
||||||
|
]
|
||||||
|
parsed = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.LLMFieldValuesResponse,
|
||||||
|
tag="field_values",
|
||||||
|
)
|
||||||
|
if parsed:
|
||||||
|
return [{"label": item.label, "value": item.value} for item in parsed.fields]
|
||||||
|
|
||||||
|
raise RuntimeError("AI field extraction failed.")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_section_draft(
|
||||||
|
prompt: str,
|
||||||
|
document_type: str,
|
||||||
|
outline_text: str,
|
||||||
|
constraints: models.Constraint | None = None,
|
||||||
|
) -> list[models.DraftSection]:
|
||||||
|
constraint_text = ""
|
||||||
|
if constraints:
|
||||||
|
tone = constraints.tone
|
||||||
|
audience = constraints.audience
|
||||||
|
pages = constraints.page_count
|
||||||
|
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||||
|
system_prompt = section_draft_system_prompt(constraint_text)
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=f"Document type: {document_type}"),
|
||||||
|
models.ChatMessage(role="user", content=f"Outline:\n{outline_text}"),
|
||||||
|
models.ChatMessage(role="user", content=f"Prompt:\n{prompt}"),
|
||||||
|
]
|
||||||
|
parsed = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.LLMDraftSectionsResponse,
|
||||||
|
tag="section_draft",
|
||||||
|
)
|
||||||
|
return parsed.sections
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML generation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template_fill_html_stream(
|
||||||
|
template_html: str,
|
||||||
|
document_type: str,
|
||||||
|
outline_text: str,
|
||||||
|
draft_sections: list[models.DraftSection] | None = None,
|
||||||
|
constraints: models.Constraint | None = None,
|
||||||
|
theme: dict[str, str] | None = None,
|
||||||
|
additional_instructions: str | None = None,
|
||||||
|
has_logo: bool = False,
|
||||||
|
):
|
||||||
|
"""Fill an HTML template by replacing {{PLACEHOLDER}} tokens, streaming chunks."""
|
||||||
|
constraints_text = ""
|
||||||
|
if constraints:
|
||||||
|
tone = constraints.tone
|
||||||
|
audience = constraints.audience
|
||||||
|
pages = constraints.page_count
|
||||||
|
constraints_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||||
|
if draft_sections:
|
||||||
|
constraints_text += "\nUse the section content to inform placeholder values."
|
||||||
|
|
||||||
|
# Inject theme before sending to AI if overrides are provided
|
||||||
|
if theme:
|
||||||
|
template_html = inject_theme(template_html, theme)
|
||||||
|
|
||||||
|
system_prompt = template_fill_html_system_prompt(constraints_text)
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=f"Document type: {document_type}"),
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=(
|
||||||
|
"CRITICAL EXAMPLE — What to preserve vs. what to change:\n"
|
||||||
|
"If template has:\n"
|
||||||
|
" <div class='vendor-name'>{{VENDOR_NAME}}</div>\n"
|
||||||
|
"You MUST output:\n"
|
||||||
|
" <div class='vendor-name'>Acme Corporation</div> ← fill the token, keep the tag\n"
|
||||||
|
"\n"
|
||||||
|
"For multi-row content like {{LINEITEM_ROWS}}, generate full <tr>...</tr> HTML.\n"
|
||||||
|
"Match ONLY the columns present in the template's <thead> — do NOT add extra columns.\n"
|
||||||
|
"Example (4-column table: #, Description, Qty, Line Total):\n"
|
||||||
|
" <tr><td>1</td><td>Office chairs</td><td class='num'>4</td><td class='num'>$480.00</td></tr>\n"
|
||||||
|
"\n"
|
||||||
|
"Keep ALL HTML tags, CSS, classes, and IDs EXACTLY as-is.\n"
|
||||||
|
"Do NOT modify any <style> blocks, structural HTML, or class names."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"Outline/context (data to fill into template):\n{outline_text}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if has_logo:
|
||||||
|
messages.append(
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=(
|
||||||
|
"LOGO:\n"
|
||||||
|
"- A company logo is available.\n"
|
||||||
|
"- Insert the exact placeholder text {{LOGO_BLOCK}} once where the logo should appear.\n"
|
||||||
|
"- Do NOT create your own <img> for the logo; only place {{LOGO_BLOCK}}.\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if additional_instructions and additional_instructions.strip():
|
||||||
|
messages.append(
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=(
|
||||||
|
"ADDITIONAL INSTRUCTIONS (highest priority):\n"
|
||||||
|
f"{additional_instructions.strip()}\n\n"
|
||||||
|
"Apply these instructions while preserving the template structure."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=(
|
||||||
|
f"COMPLETE HTML TEMPLATE (copy ALL of this, replacing ONLY {{{{PLACEHOLDER}}}} tokens):\n\n{template_html}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if draft_sections:
|
||||||
|
sections_payload = [section.model_dump(by_alias=True, exclude_none=True) for section in draft_sections]
|
||||||
|
messages.append(
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"Section content (JSON):\n{json.dumps(sections_payload, ensure_ascii=True)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = stream_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
tag="html_template_fill_stream",
|
||||||
|
log_label="html-template-fill-stream",
|
||||||
|
)
|
||||||
|
yield from stream
|
||||||
|
if stream.error or not stream.chunks:
|
||||||
|
raise RuntimeError(f"AI HTML template fill failed: {stream.error or 'no chunks streamed'}")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html_with_llm(
|
||||||
|
prompt: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
document_type: str,
|
||||||
|
template_hint: str | None = None,
|
||||||
|
current_html: str | None = None,
|
||||||
|
structured_brief: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Call the LLM for freeform HTML document generation."""
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=html_system_prompt(document_type, template_hint))
|
||||||
|
]
|
||||||
|
messages.extend(history)
|
||||||
|
messages.extend(html_context_messages(template_hint, current_html, structured_brief))
|
||||||
|
messages.append(models.ChatMessage(role="user", content=prompt))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[AI] HTML generation model=%s doc_type=%s template_hint=%s",
|
||||||
|
SMART_MODEL,
|
||||||
|
document_type,
|
||||||
|
"yes" if template_hint else "no",
|
||||||
|
)
|
||||||
|
parsed = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.HtmlResponse,
|
||||||
|
tag="html_generate",
|
||||||
|
)
|
||||||
|
if parsed and parsed.html and parsed.html.strip():
|
||||||
|
logger.info("[AI] Generated HTML preview (first 500 chars):\n%s", parsed.html[:500])
|
||||||
|
return parsed.html
|
||||||
|
|
||||||
|
raise RuntimeError("AI HTML generation failed or returned empty output.")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html_with_llm_stream(
|
||||||
|
prompt: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
document_type: str,
|
||||||
|
template_hint: str | None = None,
|
||||||
|
current_html: str | None = None,
|
||||||
|
structured_brief: str | None = None,
|
||||||
|
):
|
||||||
|
"""Stream HTML generation from LLM, yielding chunks as they arrive."""
|
||||||
|
if not STREAMING_ENABLED:
|
||||||
|
full_html = generate_html_with_llm(
|
||||||
|
prompt,
|
||||||
|
history,
|
||||||
|
document_type,
|
||||||
|
template_hint,
|
||||||
|
current_html,
|
||||||
|
structured_brief,
|
||||||
|
)
|
||||||
|
yield full_html
|
||||||
|
return
|
||||||
|
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=html_system_prompt(document_type, template_hint))
|
||||||
|
]
|
||||||
|
messages.extend(history)
|
||||||
|
messages.extend(html_context_messages(template_hint, current_html, structured_brief))
|
||||||
|
messages.append(models.ChatMessage(role="user", content=prompt))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[AI] Streaming HTML model=%s doc_type=%s template_hint=%s",
|
||||||
|
SMART_MODEL,
|
||||||
|
document_type,
|
||||||
|
"yes" if template_hint else "no",
|
||||||
|
)
|
||||||
|
stream = stream_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
tag="html_stream",
|
||||||
|
log_label="html-stream",
|
||||||
|
)
|
||||||
|
yield from stream
|
||||||
|
|
||||||
|
if stream.error or not stream.chunks:
|
||||||
|
raise RuntimeError(f"AI HTML streaming failed: {stream.error or 'no chunks produced'}")
|
||||||
|
|
||||||
|
|
||||||
|
def edit_html_with_llm_stream(
|
||||||
|
*,
|
||||||
|
document_type: str,
|
||||||
|
base_html: str,
|
||||||
|
instructions: str,
|
||||||
|
):
|
||||||
|
"""Stream an updated full HTML document by editing an existing HTML input."""
|
||||||
|
if not base_html or not base_html.strip():
|
||||||
|
raise RuntimeError("Missing base_html for HTML revision")
|
||||||
|
if not instructions or not instructions.strip():
|
||||||
|
raise RuntimeError("Missing instructions for HTML revision")
|
||||||
|
|
||||||
|
system_prompt = html_edit_system_prompt(document_type)
|
||||||
|
messages: list[models.ChatMessage] = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"CURRENT_HTML (you MUST preserve everything not explicitly requested):\n\n{base_html}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
messages.append(
|
||||||
|
models.ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"INSTRUCTIONS:\n{instructions}\n\nReturn ONLY the full updated HTML document.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = stream_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
tag="html_edit_stream",
|
||||||
|
log_label="html-edit-stream",
|
||||||
|
)
|
||||||
|
yield from stream
|
||||||
|
|
||||||
|
if stream.error or not stream.chunks:
|
||||||
|
raise RuntimeError(f"AI HTML edit streaming failed: {stream.error or 'no chunks produced'}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"generate_field_values",
|
||||||
|
"generate_html_with_llm",
|
||||||
|
"generate_html_with_llm_stream",
|
||||||
|
"generate_outline_with_llm",
|
||||||
|
"generate_section_draft",
|
||||||
|
"generate_template_fill_html_stream",
|
||||||
|
"edit_html_with_llm_stream",
|
||||||
|
]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
PostHog analytics tracking for Stirling PDF AI document generation.
|
||||||
|
|
||||||
|
Tracks LLM usage, costs, latency, and document generation metrics for client insights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
FAST_MODEL,
|
||||||
|
FAST_MODEL_REASONING_EFFORT,
|
||||||
|
FAST_MODEL_TEXT_VERBOSITY,
|
||||||
|
POSTHOG_CLIENT,
|
||||||
|
SMART_MODEL,
|
||||||
|
SMART_MODEL_REASONING_EFFORT,
|
||||||
|
SMART_MODEL_TEXT_VERBOSITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def track_event(
|
||||||
|
user_id: str | None,
|
||||||
|
event_name: str,
|
||||||
|
properties: dict[str, Any] | None = None,
|
||||||
|
include_model_settings: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Track a generic event to PostHog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier (can be anonymous ID or user email)
|
||||||
|
event_name: Name of the event (e.g., "document_generated")
|
||||||
|
properties: Additional event properties
|
||||||
|
include_model_settings: Include GPT-5 reasoning/verbosity settings in properties
|
||||||
|
"""
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_props = properties or {}
|
||||||
|
|
||||||
|
# Add model configuration settings for performance tracking
|
||||||
|
if include_model_settings:
|
||||||
|
event_props.update(
|
||||||
|
{
|
||||||
|
"smart_model": SMART_MODEL,
|
||||||
|
"fast_model": FAST_MODEL,
|
||||||
|
"smart_reasoning_effort": SMART_MODEL_REASONING_EFFORT,
|
||||||
|
"smart_text_verbosity": SMART_MODEL_TEXT_VERBOSITY,
|
||||||
|
"fast_reasoning_effort": FAST_MODEL_REASONING_EFFORT,
|
||||||
|
"fast_text_verbosity": FAST_MODEL_TEXT_VERBOSITY,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
POSTHOG_CLIENT.capture(
|
||||||
|
distinct_id=user_id,
|
||||||
|
event=event_name,
|
||||||
|
properties=event_props,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to track PostHog event %s: %s", event_name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def track_session_created(
|
||||||
|
user_id: str | None,
|
||||||
|
session_id: str,
|
||||||
|
doc_type: str,
|
||||||
|
template_id: str | None = None,
|
||||||
|
has_template: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Track when a new AI document generation session is created.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
session_id: Session ID
|
||||||
|
doc_type: Document type
|
||||||
|
template_id: Template ID if using a template
|
||||||
|
has_template: Whether user provided a custom template
|
||||||
|
"""
|
||||||
|
properties = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"doc_type": doc_type,
|
||||||
|
"has_template": has_template,
|
||||||
|
}
|
||||||
|
if template_id:
|
||||||
|
properties["template_id"] = template_id
|
||||||
|
|
||||||
|
# Include model settings to track performance across different configurations
|
||||||
|
track_event(user_id, "session_created", properties, include_model_settings=True)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
"""Gracefully shutdown PostHog client and flush pending events."""
|
||||||
|
try:
|
||||||
|
POSTHOG_CLIENT.shutdown()
|
||||||
|
logger.info("PostHog client shutdown completed")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error during PostHog shutdown: %s", exc)
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, request, send_file, send_from_directory, stream_with_context
|
||||||
|
from flask_cors import CORS
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from werkzeug.exceptions import NotFound
|
||||||
|
from werkzeug.security import safe_join
|
||||||
|
|
||||||
|
import analytics
|
||||||
|
import models
|
||||||
|
from ai_generation import generate_field_values
|
||||||
|
from briefs import _preprocess_intent
|
||||||
|
from chat_router import classify_chat_route
|
||||||
|
from config import (
|
||||||
|
ASSETS_DIR,
|
||||||
|
FAST_MODEL,
|
||||||
|
FLASK_DEBUG,
|
||||||
|
JAVA_BACKEND_API_KEY,
|
||||||
|
JAVA_REQUEST_TIMEOUT_SECONDS,
|
||||||
|
OUTPUT_DIR,
|
||||||
|
SMART_MODEL,
|
||||||
|
logger,
|
||||||
|
model_max_tokens,
|
||||||
|
)
|
||||||
|
from document_types import detect_document_type
|
||||||
|
from editing import register_edit_routes
|
||||||
|
from editing.decisions import answer_conversational_info
|
||||||
|
from file_processing_agent import ToolCatalogService
|
||||||
|
from html_pdf_utils import compile_html_to_pdf
|
||||||
|
from html_utils import inject_theme
|
||||||
|
from java_client import java_headers, java_url
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from pdf_generator import _DEFAULT_TEMPLATES_DIR, PDFGenerator
|
||||||
|
from pdf_text_editor import convert_pdf_to_text_editor_document
|
||||||
|
from prompts import (
|
||||||
|
generate_all_sections_system_prompt,
|
||||||
|
pdf_qa_system_prompt,
|
||||||
|
section_fill_system_prompt,
|
||||||
|
)
|
||||||
|
from smart_folder_creator import create_smart_folder_config
|
||||||
|
from storage import load_versions
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
_tool_catalog_service = ToolCatalogService()
|
||||||
|
register_edit_routes(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def log_job_request_sequence() -> None:
|
||||||
|
job_id = request.headers.get("X-Job-Id")
|
||||||
|
if not job_id:
|
||||||
|
return
|
||||||
|
seq = request.headers.get("X-Job-Seq", "?")
|
||||||
|
total = request.headers.get("X-Job-Total", "?")
|
||||||
|
logger.info("[HTTP] job_id=%s req=%s/%s %s %s", job_id, seq, total, request.method, request.path)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body[T: BaseModel](model: type[T], request_type: Literal["GET", "POST"] = "POST") -> T:
|
||||||
|
if request_type == "GET":
|
||||||
|
payload = request.args.to_dict()
|
||||||
|
else:
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
return model.model_validate(payload or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _java_request_json[T: BaseModel](
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
payload: BaseModel | None,
|
||||||
|
response_model: type[T],
|
||||||
|
) -> T:
|
||||||
|
url = java_url(path)
|
||||||
|
data = None
|
||||||
|
headers = java_headers()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if payload is not None:
|
||||||
|
payload_data = payload.model_dump(by_alias=True, exclude_none=True)
|
||||||
|
data = json.dumps(payload_data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=JAVA_REQUEST_TIMEOUT_SECONDS) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
parsed = json.loads(body) if body else {}
|
||||||
|
return response_model.model_validate(parsed)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8") if exc.fp else ""
|
||||||
|
logger.error("[JAVA] %s %s failed status=%s detail=%s", method, path, exc.code, detail)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ai_session(session_id: str):
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
api_key = request.headers.get("X-API-KEY")
|
||||||
|
if auth_header or api_key:
|
||||||
|
path = f"/api/v1/ai/create/sessions/{session_id}"
|
||||||
|
elif JAVA_BACKEND_API_KEY:
|
||||||
|
path = f"/api/v1/ai/create/internal/sessions/{session_id}"
|
||||||
|
else:
|
||||||
|
path = f"/api/v1/ai/create/sessions/{session_id}"
|
||||||
|
return _java_request_json("GET", path, None, models.AISession)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_ai_session(session_id: str, payload: models.JavaUpdateSessionRequest):
|
||||||
|
return _java_request_json(
|
||||||
|
"POST", f"/api/v1/ai/create/internal/sessions/{session_id}/update", payload, models.AISession
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/intent/check", methods=["POST"])
|
||||||
|
def intent_check():
|
||||||
|
payload = _json_body(models.IntentCheckRequest)
|
||||||
|
prompt = payload.prompt
|
||||||
|
history = payload.conversation_history
|
||||||
|
current_pdf_url = payload.current_pdf_url
|
||||||
|
intent = _preprocess_intent(prompt, history, bool(current_pdf_url))
|
||||||
|
response = intent.model_copy(update={"doc_type": intent.document_type, "has_pdf": bool(current_pdf_url)})
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/chat/route", methods=["POST"])
|
||||||
|
def chat_route():
|
||||||
|
payload = _json_body(models.ChatRouteRequest)
|
||||||
|
decision = classify_chat_route(payload)
|
||||||
|
return jsonify(decision.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/chat/create-smart-folder", methods=["POST"])
|
||||||
|
def create_smart_folder():
|
||||||
|
payload = _json_body(models.SmartFolderCreateRequest)
|
||||||
|
result = create_smart_folder_config(payload)
|
||||||
|
return jsonify(result.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/chat/interpret-parameter", methods=["POST"])
|
||||||
|
def interpret_parameter():
|
||||||
|
"""Use AI to interpret an ambiguous parameter response during Tier 2 workflow collection."""
|
||||||
|
payload = _json_body(models.InterpretParameterRequest)
|
||||||
|
system_prompt = (
|
||||||
|
"You are a parameter extraction assistant for a PDF tool workflow.\n"
|
||||||
|
"Given a question asked to the user and the user's response, decide how to interpret it.\n\n"
|
||||||
|
"Return JSON with these fields:\n"
|
||||||
|
'- "type": one of "value", "confused", "cancel", "default"\n'
|
||||||
|
' - "value": the user gave a usable answer, extract it cleanly\n'
|
||||||
|
' - "confused": the user is asking for help or does not understand\n'
|
||||||
|
' - "cancel": the user wants to stop or cancel\n'
|
||||||
|
' - "default": the user is vague; suggest the most sensible default\n'
|
||||||
|
'- "extracted_value": the clean extracted value (only for type="value" or type="default")\n'
|
||||||
|
'- "help_message": a short helpful message explaining what was interpreted or what options exist\n\n'
|
||||||
|
'Be concise. For type="value", extracted_value should be just the raw value (e.g. "English", "3", "DOCX").\n'
|
||||||
|
'For type="default", extracted_value should be the suggested default value.'
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
f"Tool: {payload.tool_name}\nQuestion asked: {payload.question}\nUser response: {payload.user_response}"
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=user_prompt),
|
||||||
|
]
|
||||||
|
result = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
models.InterpretParameterResponse,
|
||||||
|
tag="interpret_parameter",
|
||||||
|
log_label="interpret-parameter",
|
||||||
|
)
|
||||||
|
return jsonify(result.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/chat/infer-tools", methods=["POST"])
|
||||||
|
def infer_tools():
|
||||||
|
"""Use AI to infer which PDF tools to apply from a freeform user message."""
|
||||||
|
payload = _json_body(models.InferToolsRequest)
|
||||||
|
tools_list = "\n".join(f"- {t}" for t in payload.available_tools)
|
||||||
|
system_prompt = (
|
||||||
|
"You are a tool selection assistant for Stirling PDF.\n"
|
||||||
|
"Given a user message, identify which PDF tools to apply to achieve the user's goal.\n"
|
||||||
|
"Return tools in the execution order.\n\n"
|
||||||
|
"Rules:\n"
|
||||||
|
"- Only return tools with HIGH confidence — leave out anything uncertain\n"
|
||||||
|
"- Return empty list if the request is a question, ambiguous, or conversational\n"
|
||||||
|
"- Return empty list if the request is about creating a new document\n"
|
||||||
|
"- Maximum 4 tools\n\n"
|
||||||
|
f"Available tools (format: 'id: Display Name'):\n{tools_list}\n\n"
|
||||||
|
'Return JSON: { "tools": [{"tool_id": "...", "confidence": "high"|"medium"|"low"}], "reason": "..." }\n'
|
||||||
|
'IMPORTANT: tool_id must be the exact identifier before the colon (e.g. "convert", not "convert: Convert PDF").'
|
||||||
|
)
|
||||||
|
messages_list = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=f"User message: {payload.message}"),
|
||||||
|
]
|
||||||
|
result = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages_list,
|
||||||
|
models.InferToolsResponse,
|
||||||
|
tag="infer_tools",
|
||||||
|
log_label="infer-tools",
|
||||||
|
)
|
||||||
|
filtered = models.InferToolsResponse(
|
||||||
|
tools=[t for t in result.tools if t.confidence == "high"],
|
||||||
|
reason=result.reason,
|
||||||
|
)
|
||||||
|
return jsonify(filtered.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/chat/info", methods=["POST"])
|
||||||
|
def chat_info():
|
||||||
|
"""Handle conversational queries without requiring a file or session."""
|
||||||
|
payload = _json_body(models.ChatInfoRequest)
|
||||||
|
tool_catalog = ToolCatalogService()
|
||||||
|
assistant_message = answer_conversational_info(
|
||||||
|
payload.message,
|
||||||
|
payload.history,
|
||||||
|
tool_catalog,
|
||||||
|
)
|
||||||
|
response = models.ChatInfoResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/detect_type", methods=["POST"])
|
||||||
|
def detect_type():
|
||||||
|
"""
|
||||||
|
Detect document type from a prompt using a fast AI model.
|
||||||
|
|
||||||
|
This endpoint uses a cheap/fast model (FAST_MODEL) for AI classification
|
||||||
|
to minimize cost and latency.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
- prompt: The user's document request
|
||||||
|
- explicitType: If provided, skip detection and return this type
|
||||||
|
|
||||||
|
Response:
|
||||||
|
- docType: The detected type
|
||||||
|
- confidence: "medium" (AI), "low" (AI)
|
||||||
|
- method: "explicit" or "ai"
|
||||||
|
"""
|
||||||
|
payload = _json_body(models.DetectTypeRequest)
|
||||||
|
prompt = payload.prompt
|
||||||
|
explicit_type = payload.explicit_type or ""
|
||||||
|
|
||||||
|
# If user explicitly provided a type, skip detection
|
||||||
|
if explicit_type and explicit_type not in ("other", "document", "miscellaneous", ""):
|
||||||
|
logger.info("[DETECT] Using explicit doc_type=%s, skipping detection", explicit_type)
|
||||||
|
response = models.DetectTypeResponse(doc_type=explicit_type, confidence="high", method="explicit")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
if not prompt:
|
||||||
|
response = models.DetectTypeResponse(error="Missing prompt for detection")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||||
|
|
||||||
|
# Use fast AI detection (uses FAST_MODEL for cheap classification)
|
||||||
|
ai_type, confidence = detect_document_type(prompt, confidence_threshold=0.7)
|
||||||
|
logger.info("[DETECT] Fast AI detected doc_type=%s (confidence=%.2f)", ai_type, confidence)
|
||||||
|
response = models.DetectTypeResponse(
|
||||||
|
doc_type=ai_type or "other",
|
||||||
|
confidence="medium" if confidence >= 0.7 else "low",
|
||||||
|
method="ai",
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/pdf/answer", methods=["POST"])
|
||||||
|
def pdf_answer():
|
||||||
|
payload = _json_body(models.PdfAnswerRequest)
|
||||||
|
pdf_url = payload.pdf_url
|
||||||
|
question = payload.question
|
||||||
|
if not pdf_url or not question:
|
||||||
|
response = models.PdfAnswerResponse(error="Missing pdfUrl or question")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||||
|
|
||||||
|
filename = os.path.basename(pdf_url.split("?")[0])
|
||||||
|
if not filename.lower().endswith(".pdf"):
|
||||||
|
response = models.PdfAnswerResponse(error="Invalid pdf file")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||||
|
|
||||||
|
pdf_path = safe_join(OUTPUT_DIR, filename)
|
||||||
|
if pdf_path is None or not os.path.exists(pdf_path):
|
||||||
|
response = models.PdfAnswerResponse(error="PDF not found")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 404
|
||||||
|
|
||||||
|
doc = convert_pdf_to_text_editor_document(pdf_path)
|
||||||
|
|
||||||
|
pages = doc.document.pages if doc else []
|
||||||
|
snippets: list[str] = []
|
||||||
|
for page in pages:
|
||||||
|
for elem in page.text_elements:
|
||||||
|
text = elem.text
|
||||||
|
if text:
|
||||||
|
snippets.append(str(text))
|
||||||
|
if not snippets:
|
||||||
|
response = models.PdfAnswerResponse(error="No readable text in PDF")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||||
|
|
||||||
|
# Normalize and limit context
|
||||||
|
context = " ".join(snippets)
|
||||||
|
context = " ".join(context.split()) # normalize whitespace
|
||||||
|
max_context = 10000
|
||||||
|
if len(context) > max_context:
|
||||||
|
context = context[:max_context]
|
||||||
|
|
||||||
|
model_name = SMART_MODEL
|
||||||
|
system_prompt = pdf_qa_system_prompt() + "\nReturn JSON matching the provided schema."
|
||||||
|
user_prompt = f"Question: {question}\n\nPDF text:\n{context}"
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=user_prompt),
|
||||||
|
]
|
||||||
|
response = run_ai(
|
||||||
|
model_name,
|
||||||
|
messages,
|
||||||
|
models.PdfAnswer,
|
||||||
|
tag="pdf_answer",
|
||||||
|
max_tokens=220,
|
||||||
|
)
|
||||||
|
answer = response.answer.strip()
|
||||||
|
title_like = re.match(r"^why pdfs|^minimalist|^author:", answer, re.IGNORECASE)
|
||||||
|
normalized_answer = re.sub(r"\s+", " ", answer).strip().lower()
|
||||||
|
normalized_context = re.sub(r"\s+", " ", context).strip().lower()
|
||||||
|
copied_context = bool(normalized_answer) and normalized_answer in normalized_context
|
||||||
|
if title_like or copied_context:
|
||||||
|
response = models.PdfAnswerResponse(error="AI answer was invalid or echoed the source text.")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 500
|
||||||
|
|
||||||
|
response = models.PdfAnswerResponse(answer=answer, mode="model")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions", methods=["POST"])
|
||||||
|
def create_session():
|
||||||
|
"""Create a new AI session via the Java backend."""
|
||||||
|
create_session_request = _json_body(models.CreateSessionRequest)
|
||||||
|
|
||||||
|
# Doc type should ideally come from chat router; fall back to detection if needed
|
||||||
|
detection_method = "provided"
|
||||||
|
if create_session_request.doc_type in ("miscellaneous", "other", "document", "unknown", ""):
|
||||||
|
# Fall back to detection if doc_type not provided by chat router
|
||||||
|
logger.info("[SESSION] No doc_type provided, running fallback detection")
|
||||||
|
confidence_threshold = 0.7 # Only accept matches with 70%+ confidence
|
||||||
|
detected_type, confidence = detect_document_type(
|
||||||
|
create_session_request.prompt, confidence_threshold=confidence_threshold
|
||||||
|
)
|
||||||
|
if confidence >= confidence_threshold:
|
||||||
|
logger.info(
|
||||||
|
"[SESSION] Detected doc_type=%s from prompt (confidence=%.2f)",
|
||||||
|
detected_type,
|
||||||
|
confidence,
|
||||||
|
)
|
||||||
|
create_session_request.doc_type = detected_type
|
||||||
|
detection_method = "fallback_detection"
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"[SESSION] Detection confidence too low (%.2f < %.2f), using 'other'",
|
||||||
|
confidence,
|
||||||
|
confidence_threshold,
|
||||||
|
)
|
||||||
|
create_session_request.doc_type = "other"
|
||||||
|
detection_method = "fallback_low_confidence"
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"[SESSION] Using doc_type=%s from chat router",
|
||||||
|
create_session_request.doc_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _java_request_json(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/ai/create/sessions",
|
||||||
|
create_session_request,
|
||||||
|
models.JavaCreateSessionResponse,
|
||||||
|
)
|
||||||
|
session_id = result.session_id
|
||||||
|
logger.info(
|
||||||
|
"[SESSION] Created session %s doc_type=%s method=%s",
|
||||||
|
session_id,
|
||||||
|
create_session_request.doc_type,
|
||||||
|
detection_method,
|
||||||
|
)
|
||||||
|
# Track session creation in PostHog
|
||||||
|
safe_doc_type = re.sub(r"[^a-zA-Z0-9_]+", "", (create_session_request.doc_type or "").lower())
|
||||||
|
has_html_template = (_DEFAULT_TEMPLATES_DIR / f"{safe_doc_type}.html").exists()
|
||||||
|
analytics.track_session_created(
|
||||||
|
user_id=session_id,
|
||||||
|
session_id=session_id,
|
||||||
|
doc_type=create_session_request.doc_type or "unknown",
|
||||||
|
template_id=create_session_request.template_id,
|
||||||
|
has_template=has_html_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = models.CreateSessionResponse(
|
||||||
|
session_id=session_id,
|
||||||
|
doc_type=create_session_request.doc_type,
|
||||||
|
detection_method=detection_method,
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions/<session_id>/outline", methods=["POST"])
|
||||||
|
def update_outline(session_id: str):
|
||||||
|
"""Update session with approved outline."""
|
||||||
|
payload = _json_body(models.UpdateOutlineRequest)
|
||||||
|
_java_request_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/ai/create/sessions/{session_id}/outline",
|
||||||
|
payload,
|
||||||
|
models.AISession,
|
||||||
|
)
|
||||||
|
response = models.SuccessResponse(success=True)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions/<session_id>/draft", methods=["POST"])
|
||||||
|
def update_draft(session_id: str):
|
||||||
|
"""Update session with approved draft sections."""
|
||||||
|
payload = _json_body(models.UpdateDraftRequest)
|
||||||
|
_java_request_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/ai/create/sessions/{session_id}/draft",
|
||||||
|
payload,
|
||||||
|
models.AISession,
|
||||||
|
)
|
||||||
|
response = models.SuccessResponse(success=True)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions/<session_id>/template", methods=["POST"])
|
||||||
|
def update_template(session_id: str):
|
||||||
|
"""Update session template selection."""
|
||||||
|
payload = _json_body(models.UpdateTemplateRequest)
|
||||||
|
_java_request_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/ai/create/sessions/{session_id}/template",
|
||||||
|
payload,
|
||||||
|
models.AISession,
|
||||||
|
)
|
||||||
|
response = models.SuccessResponse(success=True)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions/<session_id>/reprompt", methods=["POST"])
|
||||||
|
def reprompt_session(session_id: str):
|
||||||
|
"""Update session with new prompt."""
|
||||||
|
payload = _json_body(models.RepromptRequest)
|
||||||
|
_java_request_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/ai/create/sessions/{session_id}/reprompt",
|
||||||
|
payload,
|
||||||
|
models.AISession,
|
||||||
|
)
|
||||||
|
response = models.SuccessResponse(success=True)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions/<session_id>/stream", methods=["POST"])
|
||||||
|
def create_stream(session_id: str):
|
||||||
|
payload = _json_body(models.CreateStreamRequest)
|
||||||
|
session = _fetch_ai_session(session_id)
|
||||||
|
prompt = session.prompt_latest or session.prompt_initial or ""
|
||||||
|
doc_type = session.doc_type or "other"
|
||||||
|
template_id = session.template_id
|
||||||
|
outline_text = session.outline_text or ""
|
||||||
|
outline_filename = session.outline_filename
|
||||||
|
constraints = session.outline_constraints
|
||||||
|
draft_sections = session.draft_sections
|
||||||
|
logo_base64 = payload.theme.logo_base64 if payload.theme else None
|
||||||
|
theme = payload.theme.css_overrides() if payload.theme else None
|
||||||
|
logger.info(
|
||||||
|
"[STREAM] session_id=%s has_theme=%s has_logo=%s",
|
||||||
|
session_id,
|
||||||
|
bool(theme),
|
||||||
|
bool(logo_base64),
|
||||||
|
)
|
||||||
|
base_html = payload.base_html or session.polished_html or None
|
||||||
|
handler = PDFGenerator(
|
||||||
|
session_id=session_id,
|
||||||
|
phase=payload.phase,
|
||||||
|
prompt=prompt,
|
||||||
|
doc_type=doc_type,
|
||||||
|
template_id=template_id,
|
||||||
|
outline_text=outline_text,
|
||||||
|
outline_filename=outline_filename,
|
||||||
|
constraints=constraints,
|
||||||
|
draft_sections=draft_sections,
|
||||||
|
update_session=_update_ai_session,
|
||||||
|
theme=theme,
|
||||||
|
logo_base64=logo_base64,
|
||||||
|
base_html=base_html,
|
||||||
|
instructions=payload.additional_instructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
stream_with_context(handler.generate()),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/create/sessions/<session_id>/fields", methods=["POST"])
|
||||||
|
def fill_fields(session_id: str):
|
||||||
|
logger.info("[AI create] fill_fields session_id=%s", session_id)
|
||||||
|
session = _fetch_ai_session(session_id)
|
||||||
|
|
||||||
|
payload = _json_body(models.FillFieldsRequest)
|
||||||
|
fields = payload.fields
|
||||||
|
extra_prompt = payload.extra_prompt
|
||||||
|
if not isinstance(fields, list):
|
||||||
|
return jsonify({"error": "Fields must be a list"}), 400
|
||||||
|
|
||||||
|
prompt = session.prompt_latest or session.prompt_initial or ""
|
||||||
|
if extra_prompt:
|
||||||
|
prompt = f"{prompt}\n{extra_prompt}"
|
||||||
|
doc_type = session.doc_type or "other"
|
||||||
|
constraints = session.outline_constraints
|
||||||
|
|
||||||
|
filled = generate_field_values(prompt, doc_type, fields, constraints)
|
||||||
|
response = models.FillFieldsResponse(fields=filled)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/generate_section", methods=["POST"])
|
||||||
|
def generate_section():
|
||||||
|
"""Generate content for a single section based on a custom prompt."""
|
||||||
|
payload = _json_body(models.GenerateSectionRequest)
|
||||||
|
_session_id = payload.session_id
|
||||||
|
section_label = payload.section_label
|
||||||
|
section_index = payload.section_index
|
||||||
|
custom_prompt = payload.custom_prompt
|
||||||
|
document_prompt = payload.document_prompt
|
||||||
|
document_type = payload.doc_type
|
||||||
|
existing_sections = payload.existing_sections
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[AI] generate_section label=%s custom_prompt=%s", section_label, custom_prompt[:50] if custom_prompt else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build context from existing sections (don't truncate - AI needs full context for calculations)
|
||||||
|
sections_context = "\n".join([f"- {sec.label}: {sec.value}" for sec in existing_sections if sec.value])
|
||||||
|
system_prompt = section_fill_system_prompt(
|
||||||
|
document_type,
|
||||||
|
document_prompt[:500],
|
||||||
|
sections_context,
|
||||||
|
section_label,
|
||||||
|
custom_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=f"Generate content for: {section_label}"),
|
||||||
|
]
|
||||||
|
parsed = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.SectionContent,
|
||||||
|
tag="generate_section",
|
||||||
|
max_tokens=500,
|
||||||
|
)
|
||||||
|
response = models.GenerateSectionResponse(content=parsed.content.strip(), section_index=section_index)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sections_batch(
|
||||||
|
batch: list[tuple[int, str]],
|
||||||
|
document_prompt: str,
|
||||||
|
additional_prompt: str | None,
|
||||||
|
system_prompt: str,
|
||||||
|
) -> dict[int, str]:
|
||||||
|
sections_list = "\n".join([f"{i + 1}. {label}" for i, label in batch])
|
||||||
|
user_prompt = (
|
||||||
|
f"User's document request:\n{document_prompt}\n\n"
|
||||||
|
"Generate ONLY these sections (leave others unchanged):\n"
|
||||||
|
f"{sections_list}"
|
||||||
|
)
|
||||||
|
if additional_prompt:
|
||||||
|
user_prompt = f"{user_prompt}\n\nAdditional instructions:\n{additional_prompt}"
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=system_prompt),
|
||||||
|
models.ChatMessage(role="user", content=user_prompt),
|
||||||
|
]
|
||||||
|
parsed = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.LLMGenerateAllSectionsResponse,
|
||||||
|
tag="generate_all_sections",
|
||||||
|
max_tokens=model_max_tokens(SMART_MODEL),
|
||||||
|
)
|
||||||
|
return {section.index - 1: section.value for section in parsed.sections if section.value}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/generate_all_sections", methods=["POST"])
|
||||||
|
def generate_all_sections():
|
||||||
|
"""Generate content for selected sections."""
|
||||||
|
generation_start = time.time()
|
||||||
|
payload = _json_body(models.GenerateAllSectionsRequest)
|
||||||
|
_session_id = payload.session_id
|
||||||
|
document_prompt = payload.document_prompt
|
||||||
|
document_type = payload.doc_type
|
||||||
|
current_sections = payload.sections
|
||||||
|
only_indices = payload.only_indices # Optional: only generate for these indices
|
||||||
|
additional_prompt = payload.additional_prompt # Optional: extra instructions from user
|
||||||
|
|
||||||
|
# If onlyIndices is provided, only generate for those sections
|
||||||
|
if only_indices is not None:
|
||||||
|
indices_to_generate = set(only_indices)
|
||||||
|
else:
|
||||||
|
indices_to_generate = set(range(len(current_sections)))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[AI] generate_all_sections doc_type=%s total_sections=%d generating=%d",
|
||||||
|
document_type,
|
||||||
|
len(current_sections),
|
||||||
|
len(indices_to_generate),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_sections:
|
||||||
|
response = models.GenerateAllSectionsResponse(error="No sections provided")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||||
|
|
||||||
|
if not indices_to_generate:
|
||||||
|
# Nothing to generate, return sections as-is
|
||||||
|
response = models.GenerateAllSectionsResponse(sections=current_sections)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
# Build the section labels list (only for sections we're generating)
|
||||||
|
section_labels = [section.label for section in current_sections]
|
||||||
|
|
||||||
|
# Build prompt only for sections we want to generate
|
||||||
|
sections_to_generate = [(i, section_labels[i]) for i in sorted(indices_to_generate) if i < len(current_sections)]
|
||||||
|
system_prompt = generate_all_sections_system_prompt(document_type)
|
||||||
|
|
||||||
|
# Batch to avoid hitting model output token limits — each batch is a separate API call.
|
||||||
|
batch_size = 3
|
||||||
|
batches = [sections_to_generate[i : i + batch_size] for i in range(0, len(sections_to_generate), batch_size)]
|
||||||
|
|
||||||
|
generated_content: dict[int, str] = {}
|
||||||
|
failed_indices: list[int] = []
|
||||||
|
|
||||||
|
for batch in batches:
|
||||||
|
try:
|
||||||
|
generated_content.update(_run_sections_batch(batch, document_prompt, additional_prompt, system_prompt))
|
||||||
|
except Exception as batch_err:
|
||||||
|
logger.warning(
|
||||||
|
"[AI] generate_all_sections batch failed (%s), retrying each section individually",
|
||||||
|
batch_err,
|
||||||
|
)
|
||||||
|
for item in batch:
|
||||||
|
try:
|
||||||
|
generated_content.update(
|
||||||
|
_run_sections_batch([item], document_prompt, additional_prompt, system_prompt)
|
||||||
|
)
|
||||||
|
except Exception as single_err:
|
||||||
|
logger.error(
|
||||||
|
"[AI] generate_all_sections failed for section index=%d label=%r: %s",
|
||||||
|
item[0],
|
||||||
|
item[1],
|
||||||
|
single_err,
|
||||||
|
)
|
||||||
|
failed_indices.append(item[0])
|
||||||
|
|
||||||
|
# Build final sections list, keeping existing content for non-generated sections
|
||||||
|
filled_sections = []
|
||||||
|
for i, section in enumerate(current_sections):
|
||||||
|
label = section.label
|
||||||
|
if i in generated_content:
|
||||||
|
# Use newly generated content
|
||||||
|
filled_sections.append(models.DraftSection(label=label, value=generated_content[i]))
|
||||||
|
else:
|
||||||
|
# Keep existing content
|
||||||
|
filled_sections.append(models.DraftSection(label=label, value=section.value))
|
||||||
|
|
||||||
|
generation_duration = (time.time() - generation_start) * 1000
|
||||||
|
|
||||||
|
# Track section generation
|
||||||
|
analytics.track_event(
|
||||||
|
user_id=_session_id or "unknown",
|
||||||
|
event_name="sections_generated",
|
||||||
|
properties={
|
||||||
|
"session_id": _session_id,
|
||||||
|
"doc_type": document_type,
|
||||||
|
"total_sections": len(current_sections),
|
||||||
|
"generated_sections": len(indices_to_generate),
|
||||||
|
"generation_time_ms": generation_duration,
|
||||||
|
"has_additional_prompt": bool(additional_prompt),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = models.GenerateAllSectionsResponse(
|
||||||
|
sections=filled_sections,
|
||||||
|
incomplete_section_indices=failed_indices if failed_indices else None,
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/output/<path:filename>", methods=["GET"])
|
||||||
|
def serve_output_file(filename: str):
|
||||||
|
"""Serve generated PDF files and stored assets."""
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename)
|
||||||
|
try:
|
||||||
|
response = send_from_directory(OUTPUT_DIR, filename, mimetype=mime_type or "application/pdf")
|
||||||
|
file_size = response.headers.get("Content-Length", "unknown")
|
||||||
|
logger.info("[SERVE] Serving file=%s size=%s bytes mime=%s", filename, file_size, mime_type)
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
response.headers["Access-Control-Allow-Methods"] = "GET"
|
||||||
|
return response
|
||||||
|
except NotFound:
|
||||||
|
logger.warning("[SERVE] File not found: %s", filename)
|
||||||
|
return jsonify({"error": "File not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/versions/<user_id>", methods=["GET"])
|
||||||
|
def list_versions(user_id: str):
|
||||||
|
response = models.VersionsResponse(versions=load_versions(user_id))
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/assets/upload", methods=["POST"])
|
||||||
|
def upload_asset():
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file:
|
||||||
|
return jsonify({"error": "Missing file"}), 400
|
||||||
|
|
||||||
|
_, ext = os.path.splitext(file.filename or "")
|
||||||
|
ext = ext.lower()
|
||||||
|
if ext not in {".png", ".jpg", ".jpeg", ".gif"}:
|
||||||
|
return jsonify({"error": "Unsupported file type"}), 400
|
||||||
|
|
||||||
|
asset_id = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
output_path = os.path.join(ASSETS_DIR, asset_id)
|
||||||
|
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||||
|
file.save(output_path)
|
||||||
|
|
||||||
|
response = models.UploadAssetResponse(
|
||||||
|
asset_id=asset_id,
|
||||||
|
asset_url=f"/output/assets/{asset_id}",
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/templates/previews", methods=["GET"])
|
||||||
|
def list_preview_templates():
|
||||||
|
"""Return sorted list of preview template stem names."""
|
||||||
|
stems = sorted(p.stem for p in _DEFAULT_TEMPLATES_DIR.glob("*_preview.html"))
|
||||||
|
return jsonify({"templates": stems})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/templates/preview-html", methods=["GET"])
|
||||||
|
def get_template_preview_html():
|
||||||
|
"""Return preview template HTML with default theme injected.
|
||||||
|
|
||||||
|
Query param: template — stem name (e.g. 'invoice_preview' or 'invoice_preview.html').
|
||||||
|
The frontend will override CSS vars via JS for live color preview.
|
||||||
|
"""
|
||||||
|
payload = _json_body(models.PreviewTemplateHtmlRequest, "GET")
|
||||||
|
template_param = payload.template
|
||||||
|
if not template_param:
|
||||||
|
return jsonify({"error": "Missing template parameter"}), 400
|
||||||
|
|
||||||
|
# Strip .html extension if present
|
||||||
|
stem = template_param.removesuffix(".html")
|
||||||
|
if "_preview" not in stem:
|
||||||
|
return jsonify({"error": "Template must be a preview template (name must contain '_preview')"}), 400
|
||||||
|
|
||||||
|
html_path = _DEFAULT_TEMPLATES_DIR / f"{stem}.html"
|
||||||
|
if not html_path.exists():
|
||||||
|
return jsonify({"error": f"Template not found: {stem}"}), 404
|
||||||
|
|
||||||
|
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
# Inject default theme so vars are defined; frontend will override via JS
|
||||||
|
html = inject_theme(html, None)
|
||||||
|
return Response(html, mimetype="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/templates/render-preview", methods=["POST"])
|
||||||
|
def render_preview_to_pdf():
|
||||||
|
"""Render a preview template to PDF with the given theme.
|
||||||
|
|
||||||
|
Body: { template: str, theme: dict | None }
|
||||||
|
Returns: PDF file download.
|
||||||
|
"""
|
||||||
|
payload = _json_body(models.RenderPreviewRequest)
|
||||||
|
template_param = payload.template
|
||||||
|
theme = payload.theme.css_overrides() if payload.theme else None
|
||||||
|
|
||||||
|
if not template_param:
|
||||||
|
return jsonify({"error": "Missing template parameter"}), 400
|
||||||
|
|
||||||
|
stem = template_param.removesuffix(".html")
|
||||||
|
if "_preview" not in stem:
|
||||||
|
return jsonify({"error": "Template must be a preview template"}), 400
|
||||||
|
|
||||||
|
html_path = _DEFAULT_TEMPLATES_DIR / f"{stem}.html"
|
||||||
|
if not html_path.exists():
|
||||||
|
return jsonify({"error": f"Template not found: {stem}"}), 404
|
||||||
|
|
||||||
|
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
html = inject_theme(html, theme)
|
||||||
|
|
||||||
|
job_id = f"preview/{uuid.uuid4().hex}"
|
||||||
|
result = compile_html_to_pdf(html, job_id, log_errors=True)
|
||||||
|
|
||||||
|
if result.pdf_path and os.path.exists(result.pdf_path):
|
||||||
|
return send_file(result.pdf_path, mimetype="application/pdf", as_attachment=True, download_name=f"{stem}.pdf")
|
||||||
|
|
||||||
|
return jsonify({"error": result.error or "PDF generation failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
response = models.HealthResponse(status="ok", engine="puppeteer")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5001, debug=FLASK_DEBUG)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import FAST_MODEL, SMART_MODEL
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import ChatMessage, IntentCheckResponse, IntentClassification, MissingQuestionsResponse
|
||||||
|
from prompts import brief_missing_info_system_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BRIEF_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||||
|
"resume": {
|
||||||
|
"field_order": [
|
||||||
|
"name",
|
||||||
|
"contact",
|
||||||
|
"location",
|
||||||
|
"target_role",
|
||||||
|
"summary",
|
||||||
|
"work_history",
|
||||||
|
"education",
|
||||||
|
"skills",
|
||||||
|
"achievements",
|
||||||
|
"links",
|
||||||
|
"constraints",
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"name": ["name", "full name"],
|
||||||
|
"contact": ["contact", "contact info", "contact information", "email/phone"],
|
||||||
|
"location": ["location", "city/country"],
|
||||||
|
"target_role": ["target role", "role", "title", "headline"],
|
||||||
|
"summary": ["summary", "objective", "about"],
|
||||||
|
"work_history": ["experience", "work history", "roles"],
|
||||||
|
"education": ["education", "studies"],
|
||||||
|
"skills": ["skills", "stack"],
|
||||||
|
"achievements": ["achievements", "certifications", "awards"],
|
||||||
|
"links": ["links", "profiles", "linkedin/github"],
|
||||||
|
"constraints": ["constraints", "tone/length/style"],
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"name": "What's your name as you'd like it on the page?",
|
||||||
|
"contact": "How can someone reach you (email/phone)?",
|
||||||
|
"location": "Where are you based (or remote)?",
|
||||||
|
"target_role": "What role/title and industry are you aiming for?",
|
||||||
|
"summary": "Give me a 1–2 sentence summary about you.",
|
||||||
|
"work_history": "Recent roles: company, title, dates, location, and a few bullets with impact.",
|
||||||
|
"education": "Degree(s), school, and graduation year?",
|
||||||
|
"skills": "Key skills/stack (tech + relevant soft skills)?",
|
||||||
|
"achievements": "Awards/certifications/major achievements?",
|
||||||
|
"links": "Any LinkedIn/GitHub/portfolio links?",
|
||||||
|
"constraints": "Any tone/length constraints (ATS, one-page, etc.)?",
|
||||||
|
},
|
||||||
|
"intro": "Hey! To build a strong resume, you can paste your old resume or just dump everything you remember—name, how to reach you, where you're based, what you're aiming for, your roles, education, skills, links. Share whatever you have and I'll work with it.",
|
||||||
|
},
|
||||||
|
"invoice": {
|
||||||
|
"field_order": [
|
||||||
|
"your_business",
|
||||||
|
"client",
|
||||||
|
"issue_date",
|
||||||
|
"due_date",
|
||||||
|
"line_items",
|
||||||
|
"currency",
|
||||||
|
"payment_terms",
|
||||||
|
"notes",
|
||||||
|
"constraints",
|
||||||
|
],
|
||||||
|
"labels": {
|
||||||
|
"your_business": ["your business", "seller", "from"],
|
||||||
|
"client": ["client", "bill to"],
|
||||||
|
"issue_date": ["issue date", "invoice date"],
|
||||||
|
"due_date": ["due date"],
|
||||||
|
"line_items": ["line items", "services/items"],
|
||||||
|
"currency": ["currency"],
|
||||||
|
"payment_terms": ["payment terms"],
|
||||||
|
"notes": ["notes"],
|
||||||
|
"constraints": ["constraints", "layout/style"],
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"your_business": "Who is issuing the invoice (business name + contact)?",
|
||||||
|
"client": "Who is being billed (name + contact)?",
|
||||||
|
"issue_date": "Invoice issue date?",
|
||||||
|
"due_date": "Due date?",
|
||||||
|
"line_items": "Line items with description, qty, rate, tax (if any)?",
|
||||||
|
"currency": "Currency?",
|
||||||
|
"payment_terms": "Payment terms and payment methods?",
|
||||||
|
"notes": "Notes to include (late fees, thank you, PO #)?",
|
||||||
|
"constraints": "Branding/layout preferences?",
|
||||||
|
},
|
||||||
|
"intro": "I'll draft an accurate invoice if I know who is billing, who is paying, and the line items. Paste an old invoice or list the details.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_intent_with_llm(prompt: str, history: list[ChatMessage], has_pdf: bool) -> IntentClassification:
|
||||||
|
"""
|
||||||
|
Use a small model to classify intent instead of brittle regex.
|
||||||
|
|
||||||
|
Returns a dict like:
|
||||||
|
{
|
||||||
|
"docType": "invoice|resume|contract|letter|report|form|document",
|
||||||
|
"action": "new|edit|question",
|
||||||
|
"wantsPdf": bool,
|
||||||
|
"hasEnoughInfo": bool,
|
||||||
|
"missingFields": [str],
|
||||||
|
"notes": str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
system = (
|
||||||
|
"You classify user requests about documents. "
|
||||||
|
"docType must be one of: academic, agenda, brochure, business_card, case_study, checklist, "
|
||||||
|
"contract, creative, datasheet, document, flyer, invoice, letter, manual, menu, minutes, newsletter, "
|
||||||
|
"one_pager, poster, presentation, press_release, proposal, recipe, report, resume, timeline, whitepaper. "
|
||||||
|
"action: 'new' (make/generate), 'edit' (modify existing), 'question' (asking about it). "
|
||||||
|
"wantsPdf: true if they expect/gave permission to generate a PDF. "
|
||||||
|
"hasEnoughInfo: true if there is enough info to proceed without fabricating data. "
|
||||||
|
"missingFields: key details still needed (e.g., for invoice: seller, client, line items; resume: name, contact, work). "
|
||||||
|
"notes: short free-form note. "
|
||||||
|
"Return the classification using the provided schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation = [ChatMessage(role="system", content=system)]
|
||||||
|
# Trim history to keep request small
|
||||||
|
trimmed_history = history[-6:] if len(history) > 6 else history
|
||||||
|
conversation.extend(trimmed_history)
|
||||||
|
conversation.append(ChatMessage(role="user", content=prompt))
|
||||||
|
|
||||||
|
parsed = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
conversation,
|
||||||
|
IntentClassification,
|
||||||
|
tag="intent_classify",
|
||||||
|
max_tokens=20000,
|
||||||
|
)
|
||||||
|
result = parsed
|
||||||
|
logger.info(
|
||||||
|
"[INTENT] llm_classify doc_type=%s action=%s wantsPdf=%s hasEnoughInfo=%s missing=%s notes=%s",
|
||||||
|
result.doc_type,
|
||||||
|
result.action,
|
||||||
|
result.wants_pdf,
|
||||||
|
result.has_enough_info,
|
||||||
|
result.missing_fields,
|
||||||
|
(result.notes or "")[:120],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_intent(
|
||||||
|
prompt: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
has_pdf: bool,
|
||||||
|
) -> IntentCheckResponse:
|
||||||
|
"""
|
||||||
|
Lightweight intent classifier used by /api/intent/check.
|
||||||
|
|
||||||
|
It is intentionally heuristic-only to avoid extra model calls. The goal is to
|
||||||
|
decide whether we should proceed with PDF generation and whether it's OK to
|
||||||
|
fabricate placeholder content when the user explicitly asks for it.
|
||||||
|
"""
|
||||||
|
llm = classify_intent_with_llm(prompt, history, has_pdf)
|
||||||
|
return IntentCheckResponse(
|
||||||
|
wants_pdf=llm.wants_pdf,
|
||||||
|
has_enough_info=llm.has_enough_info,
|
||||||
|
document_type=llm.doc_type.value,
|
||||||
|
missing_fields=llm.missing_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_structured_fields(text: str, schema: dict[str, Any]) -> dict[str, str]:
|
||||||
|
"""Naively parse user text to pull schema fields."""
|
||||||
|
found: dict[str, str] = {}
|
||||||
|
lower = text.lower()
|
||||||
|
|
||||||
|
if schema.get("field_order") == BRIEF_SCHEMAS["resume"]["field_order"]:
|
||||||
|
work_matches = re.findall(
|
||||||
|
r"(?:experience|work history|role|company|position)\s*[:\-]\s*(.+?)(?=\n\n|\Z)",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if work_matches:
|
||||||
|
found["work_history"] = "\n".join(work_matches[:3])
|
||||||
|
education_matches = re.findall(
|
||||||
|
r"(?:education|degree)\s*[:\-]\s*(.+?)(?=\n\n|\Z)",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if education_matches:
|
||||||
|
found["education"] = "\n".join(education_matches[:2])
|
||||||
|
skills_match = re.search(r"(?:skills|stack)\s*[:\-]\s*(.+)", text, flags=re.IGNORECASE)
|
||||||
|
if skills_match:
|
||||||
|
found["skills"] = skills_match.group(1).strip()
|
||||||
|
|
||||||
|
for field, labels in schema.get("labels", {}).items():
|
||||||
|
for label in labels:
|
||||||
|
pattern = rf"{label}\s*[:\-]\s*(.+?)(?=\n[A-Z][a-zA-Z ]+[:\-]|\Z)"
|
||||||
|
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
if match:
|
||||||
|
found[field] = match.group(1).strip()
|
||||||
|
break
|
||||||
|
if not found.get("summary") and len(lower) < 200:
|
||||||
|
found["summary"] = text.strip()
|
||||||
|
return {k: v for k, v in found.items() if v}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_missing_message(
|
||||||
|
doc_type: str,
|
||||||
|
schema: dict[str, Any],
|
||||||
|
collected: dict[str, str],
|
||||||
|
missing: list[str],
|
||||||
|
preface: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Fallback text asking the user for missing fields."""
|
||||||
|
intro = schema.get("intro") or f"Need a few details to finish your {doc_type}."
|
||||||
|
lines = [intro]
|
||||||
|
if preface:
|
||||||
|
lines.append(preface)
|
||||||
|
if collected:
|
||||||
|
lines.append("Already have:")
|
||||||
|
for field, value in collected.items():
|
||||||
|
label = schema.get("labels", {}).get(field, [field])[0]
|
||||||
|
lines.append(f"- {label}: {value}")
|
||||||
|
if missing:
|
||||||
|
lines.append("Still need:")
|
||||||
|
questions = schema.get("questions", {})
|
||||||
|
for field in missing[:4]:
|
||||||
|
ask = questions.get(field) or f"{field}?"
|
||||||
|
lines.append(f"- {ask}")
|
||||||
|
lines.append("Partial info is fine—share whatever you remember.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_missing_message(
|
||||||
|
doc_type: str,
|
||||||
|
schema: dict[str, Any],
|
||||||
|
collected: dict[str, str],
|
||||||
|
missing: list[str],
|
||||||
|
) -> str | None:
|
||||||
|
"""Let the model craft clarifying questions when available."""
|
||||||
|
if not missing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
collected_lines = [
|
||||||
|
f"- {schema.get('labels', {}).get(field, [field])[0]}: {value}" for field, value in collected.items()
|
||||||
|
]
|
||||||
|
missing_labels = [schema.get("labels", {}).get(field, [field])[0] for field in missing]
|
||||||
|
user_text = "We already have:\n" + "\n".join(collected_lines) if collected_lines else "We have nothing yet."
|
||||||
|
user_text += "\nNeed to ask for: " + ", ".join(missing_labels)
|
||||||
|
if not collected_lines:
|
||||||
|
user_text += "\nInvite them to paste an old resume or dump all details if they have them."
|
||||||
|
|
||||||
|
system_prompt = brief_missing_info_system_prompt(doc_type) + "\nReturn JSON that matches the provided schema."
|
||||||
|
response = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
[
|
||||||
|
ChatMessage(role="system", content=system_prompt),
|
||||||
|
ChatMessage(role="user", content=user_text),
|
||||||
|
],
|
||||||
|
MissingQuestionsResponse,
|
||||||
|
tag="missing_questions",
|
||||||
|
max_tokens=400,
|
||||||
|
)
|
||||||
|
return response.message
|
||||||
|
|
||||||
|
|
||||||
|
def gather_brief(
|
||||||
|
doc_type: str,
|
||||||
|
prompt: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
has_pdf: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Determine whether we have enough structured details to generate.
|
||||||
|
Returns needsInfo + a formatted message when details are missing, or a structured brief.
|
||||||
|
"""
|
||||||
|
classifier = classify_intent_with_llm(prompt, history, has_pdf)
|
||||||
|
if classifier:
|
||||||
|
doc_type = classifier.doc_type.value
|
||||||
|
logger.info(
|
||||||
|
"[BRIEF] using llm doc_type=%s missing=%s hasEnoughInfo=%s wantsPdf=%s",
|
||||||
|
doc_type,
|
||||||
|
classifier.missing_fields,
|
||||||
|
classifier.has_enough_info,
|
||||||
|
classifier.wants_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = BRIEF_SCHEMAS.get(doc_type)
|
||||||
|
if not schema:
|
||||||
|
return {"needsInfo": False, "structured_brief": None, "collected": {}, "missing": []}
|
||||||
|
|
||||||
|
user_texts = [entry.content for entry in history if entry.role == "user" and isinstance(entry.content, str)]
|
||||||
|
user_texts.append(prompt or "")
|
||||||
|
combined_text = "\n".join(user_texts)
|
||||||
|
collected = _extract_structured_fields(combined_text, schema)
|
||||||
|
missing = [field for field in schema.get("field_order", []) if field not in collected]
|
||||||
|
if classifier and classifier.missing_fields:
|
||||||
|
# If the model provided missing fields, respect that list.
|
||||||
|
missing = classifier.missing_fields or missing
|
||||||
|
|
||||||
|
def has_minimum_resume(data: dict[str, str]) -> bool:
|
||||||
|
has_name = bool(data.get("name"))
|
||||||
|
has_core = any(data.get(key) for key in ["work_history", "education", "skills", "contact", "target_role"])
|
||||||
|
return has_name and has_core
|
||||||
|
|
||||||
|
def has_minimum_invoice(data: dict[str, str]) -> bool:
|
||||||
|
has_parties = data.get("your_business") and data.get("client")
|
||||||
|
has_items = bool(data.get("line_items"))
|
||||||
|
return bool(has_parties and has_items)
|
||||||
|
|
||||||
|
has_minimum = True
|
||||||
|
if doc_type == "resume":
|
||||||
|
has_minimum = has_minimum_resume(collected)
|
||||||
|
elif doc_type == "invoice":
|
||||||
|
has_minimum = has_minimum_invoice(collected)
|
||||||
|
|
||||||
|
# Never block generation - always proceed with what we have
|
||||||
|
# Users can generate documents even with missing information
|
||||||
|
if False: # Disabled - never block generation
|
||||||
|
preface = None
|
||||||
|
if doc_type == "resume" and not has_minimum:
|
||||||
|
preface = (
|
||||||
|
"I only have a tiny bit so far. I need at least your name plus one of: contact, "
|
||||||
|
"a role snippet, education, skills, or target role."
|
||||||
|
)
|
||||||
|
if doc_type == "invoice" and not has_minimum:
|
||||||
|
preface = "Need who is billing, who is paying, and the line items so I don't invent details."
|
||||||
|
logger.info(
|
||||||
|
"[BRIEF] gating doc_type=%s missing=%s has_minimum=%s",
|
||||||
|
doc_type,
|
||||||
|
missing,
|
||||||
|
has_minimum,
|
||||||
|
)
|
||||||
|
message = _ai_missing_message(doc_type, schema, collected, missing) or _format_missing_message(
|
||||||
|
doc_type, schema, collected, missing, preface=preface
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"needsInfo": True,
|
||||||
|
"message": message,
|
||||||
|
"collected": collected,
|
||||||
|
"missing": missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
# If fields are missing, let downstream generation know which areas to fill in plausibly
|
||||||
|
# This allows generation to proceed even with missing information
|
||||||
|
fabrication_hint = ""
|
||||||
|
if missing:
|
||||||
|
missing_labels = [schema.get("labels", {}).get(field, [field])[0] for field in missing[:5]]
|
||||||
|
fabrication_hint = (
|
||||||
|
"\n\nIf details are absent, invent plausible, clearly fictional details for: "
|
||||||
|
+ ", ".join(missing_labels)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
structured_lines = []
|
||||||
|
for field in schema.get("field_order", []):
|
||||||
|
value = collected.get(field)
|
||||||
|
if value:
|
||||||
|
label = schema.get("labels", {}).get(field, [field])[0]
|
||||||
|
structured_lines.append(f"{label}: {value}")
|
||||||
|
structured_brief = "\n".join(structured_lines)
|
||||||
|
if fabrication_hint:
|
||||||
|
structured_brief = (structured_brief + fabrication_hint).strip()
|
||||||
|
# Always include the full user text - it contains all the details
|
||||||
|
# For detailed prompts, this ensures nothing is lost
|
||||||
|
if combined_text.strip():
|
||||||
|
if structured_brief:
|
||||||
|
structured_brief = (
|
||||||
|
structured_brief + "\n\nCOMPLETE USER INPUT (use ALL information from this):\n" + combined_text
|
||||||
|
).strip()
|
||||||
|
else:
|
||||||
|
# If extraction found nothing, use the full text as the brief
|
||||||
|
structured_brief = combined_text.strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"needsInfo": False,
|
||||||
|
"structured_brief": structured_brief or None,
|
||||||
|
"collected": collected,
|
||||||
|
"missing": missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["gather_brief", "BRIEF_SCHEMAS", "_preprocess_intent"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from config import FAST_MODEL
|
||||||
|
from format_prompts import FORMAT_PROMPTS
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import ChatMessage, ChatRouteRequest, ChatRouteResponse
|
||||||
|
from prompts import chat_route_system_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def classify_chat_route(request: ChatRouteRequest) -> ChatRouteResponse:
|
||||||
|
"""
|
||||||
|
Classify a chat message to route it to edit vs create workflows.
|
||||||
|
|
||||||
|
Returns ChatRouteResponse.
|
||||||
|
"""
|
||||||
|
# Build list of available document types for detection
|
||||||
|
available_types = sorted(list(FORMAT_PROMPTS.keys()))
|
||||||
|
types_list = ", ".join(available_types)
|
||||||
|
system_prompt = chat_route_system_prompt(types_list)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
ChatMessage(role="system", content=system_prompt),
|
||||||
|
*request.history[-6:],
|
||||||
|
ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=(
|
||||||
|
"Context:\n"
|
||||||
|
f"- has_files={request.has_files}\n"
|
||||||
|
f"- has_editable_html={request.has_editable_html}\n"
|
||||||
|
f"- has_create_session={request.has_create_session}\n"
|
||||||
|
f"- has_edit_session={request.has_edit_session}\n"
|
||||||
|
f"- last_route={request.last_route}\n\n"
|
||||||
|
f"- request_title={request.request_title}\n"
|
||||||
|
f"- title_context={request.title_context.model_dump() if request.title_context else None}\n\n"
|
||||||
|
f"Message: {request.message}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
result = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
ChatRouteResponse,
|
||||||
|
tag="chat_route",
|
||||||
|
log_label="chat-route",
|
||||||
|
log_exchange=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import platformdirs
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain_anthropic import ChatAnthropic
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from posthog import Posthog
|
||||||
|
from posthog.ai.langchain import CallbackHandler
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_log_path() -> Path:
|
||||||
|
# Allow explicit override
|
||||||
|
if os.environ["STIRLING_LOG_PATH"]:
|
||||||
|
return Path(os.environ["STIRLING_LOG_PATH"])
|
||||||
|
|
||||||
|
# Check if running in Tauri desktop mode
|
||||||
|
is_tauri = os.environ["STIRLING_PDF_TAURI_MODE"].lower() == "true"
|
||||||
|
|
||||||
|
if is_tauri:
|
||||||
|
# Use OS-native log directory via platformdirs
|
||||||
|
# On Mac: ~/Library/Logs/Stirling-PDF/
|
||||||
|
# On Windows: %LOCALAPPDATA%/Stirling-PDF/Logs/
|
||||||
|
# On Linux: ~/.local/state/Stirling-PDF/log/
|
||||||
|
return Path(platformdirs.user_log_dir("Stirling-PDF"))
|
||||||
|
|
||||||
|
# Server/Docker mode: ./logs/
|
||||||
|
return Path("./logs")
|
||||||
|
|
||||||
|
|
||||||
|
LOG_PATH = _get_log_path()
|
||||||
|
LOG_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE = LOG_PATH / "docgen.log"
|
||||||
|
|
||||||
|
# Create formatters
|
||||||
|
console_formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(thread)d] %(levelname)-5s %(name)s - %(message)s", datefmt="%H:%M:%S.%f"
|
||||||
|
)
|
||||||
|
file_formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(thread)d] %(message)s")
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Console handler
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# File handler with daily rotation, keeping 14 days
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
LOG_FILE,
|
||||||
|
when="midnight",
|
||||||
|
interval=1,
|
||||||
|
backupCount=14,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"Logging to: {LOG_FILE}")
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||||
|
ASSETS_DIR = os.path.join(OUTPUT_DIR, "assets")
|
||||||
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
|
TEMPLATE_DIR = os.path.join(BASE_DIR, "templates")
|
||||||
|
TEMPLATE_DB_PATH = os.path.join(DATA_DIR, "user_templates.json")
|
||||||
|
VERSIONS_DB_PATH = os.path.join(DATA_DIR, "versions.json")
|
||||||
|
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
os.makedirs(TEMPLATE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
OPENAI_API_KEY = os.environ["STIRLING_OPENAI_API_KEY"]
|
||||||
|
OPENAI_BASE_URL = os.environ["STIRLING_OPENAI_BASE_URL"]
|
||||||
|
ANTHROPIC_API_KEY = os.environ["STIRLING_ANTHROPIC_API_KEY"]
|
||||||
|
JAVA_BACKEND_URL = os.environ["STIRLING_JAVA_BACKEND_URL"]
|
||||||
|
JAVA_BACKEND_API_KEY = os.environ["STIRLING_JAVA_BACKEND_API_KEY"]
|
||||||
|
JAVA_REQUEST_TIMEOUT_SECONDS = float(os.environ["STIRLING_JAVA_REQUEST_TIMEOUT_SECONDS"])
|
||||||
|
|
||||||
|
if not OPENAI_API_KEY and not ANTHROPIC_API_KEY:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Either STIRLING_OPENAI_API_KEY or STIRLING_ANTHROPIC_API_KEY is required to start the AI backend."
|
||||||
|
)
|
||||||
|
SMART_MODEL = os.environ["STIRLING_SMART_MODEL"]
|
||||||
|
FAST_MODEL = os.environ["STIRLING_FAST_MODEL"]
|
||||||
|
|
||||||
|
# GPT-5 reasoning effort configuration
|
||||||
|
# Supported values: minimal, low, medium, high, xhigh
|
||||||
|
# - minimal: Fastest (GPT-5 only)
|
||||||
|
# - low: Speed focused
|
||||||
|
# - medium: Default balance
|
||||||
|
# - high: Quality focused
|
||||||
|
# - xhigh: Maximum quality (GPT-5.2 Pro/Thinking only)
|
||||||
|
SMART_MODEL_REASONING_EFFORT = os.environ["STIRLING_SMART_MODEL_REASONING_EFFORT"]
|
||||||
|
FAST_MODEL_REASONING_EFFORT = os.environ["STIRLING_FAST_MODEL_REASONING_EFFORT"]
|
||||||
|
|
||||||
|
# GPT-5 text verbosity configuration
|
||||||
|
# Supported values: minimal, low, medium, high
|
||||||
|
# Controls output length and detail level
|
||||||
|
SMART_MODEL_TEXT_VERBOSITY = os.environ["STIRLING_SMART_MODEL_TEXT_VERBOSITY"]
|
||||||
|
FAST_MODEL_TEXT_VERBOSITY = os.environ["STIRLING_FAST_MODEL_TEXT_VERBOSITY"]
|
||||||
|
|
||||||
|
FLASK_DEBUG = os.environ["STIRLING_FLASK_DEBUG"] == "1"
|
||||||
|
STREAMING_ENABLED = os.environ["STIRLING_AI_STREAMING"].lower() not in {"0", "false", "no"}
|
||||||
|
if OPENAI_BASE_URL and "ollama" in OPENAI_BASE_URL and not os.environ["STIRLING_AI_STREAMING"]:
|
||||||
|
STREAMING_ENABLED = False
|
||||||
|
PREVIEW_MAX_INFLIGHT = int(os.environ["STIRLING_AI_PREVIEW_MAX_INFLIGHT"])
|
||||||
|
AI_REQUEST_TIMEOUT_SECONDS = float(os.environ["STIRLING_AI_REQUEST_TIMEOUT"])
|
||||||
|
AI_RAW_DEBUG = os.environ["STIRLING_AI_RAW_DEBUG"].lower() not in {"", "0", "false", "no"}
|
||||||
|
AI_MESSAGES_LOG_PATH = LOG_PATH / "ai_messages"
|
||||||
|
AI_MESSAGES_LOG_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def model_max_tokens(model_name: str) -> int:
|
||||||
|
"""
|
||||||
|
Output token limit for a given model.
|
||||||
|
|
||||||
|
This is used by a few routes to avoid provider defaults that are too small for
|
||||||
|
structured outputs. All values can be overridden via env vars.
|
||||||
|
"""
|
||||||
|
if os.environ["STIRLING_AI_MAX_TOKENS"]:
|
||||||
|
return int(os.environ["STIRLING_AI_MAX_TOKENS"])
|
||||||
|
|
||||||
|
if model_name == SMART_MODEL:
|
||||||
|
return int(os.environ["STIRLING_SMART_MODEL_MAX_TOKENS"])
|
||||||
|
if model_name == FAST_MODEL:
|
||||||
|
return int(os.environ["STIRLING_FAST_MODEL_MAX_TOKENS"])
|
||||||
|
if model_name.startswith("claude"):
|
||||||
|
return int(os.environ["STIRLING_CLAUDE_MAX_TOKENS"])
|
||||||
|
|
||||||
|
return int(os.environ["STIRLING_DEFAULT_MODEL_MAX_TOKENS"])
|
||||||
|
|
||||||
|
|
||||||
|
# PostHog Analytics Configuration
|
||||||
|
POSTHOG_API_KEY = os.environ["STIRLING_POSTHOG_API_KEY"]
|
||||||
|
if not POSTHOG_API_KEY:
|
||||||
|
raise RuntimeError("STIRLING_POSTHOG_API_KEY is required to start the AI backend.")
|
||||||
|
POSTHOG_HOST = os.environ["STIRLING_POSTHOG_HOST"]
|
||||||
|
|
||||||
|
# Initialize PostHog client
|
||||||
|
POSTHOG_CLIENT = Posthog(
|
||||||
|
project_api_key=POSTHOG_API_KEY,
|
||||||
|
host=POSTHOG_HOST,
|
||||||
|
)
|
||||||
|
logger.info(f"PostHog analytics enabled: host={POSTHOG_HOST}")
|
||||||
|
|
||||||
|
POSTHOG_CALLBACK = CallbackHandler(client=POSTHOG_CLIENT)
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_model(
|
||||||
|
model_name: str,
|
||||||
|
streaming: bool = False,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
model_kwargs: dict | None = None,
|
||||||
|
callbacks: list | None = None,
|
||||||
|
):
|
||||||
|
# Add PostHog callback if enabled and not already in callbacks
|
||||||
|
if callbacks is None or POSTHOG_CALLBACK not in callbacks:
|
||||||
|
callbacks = [POSTHOG_CALLBACK] if callbacks is None else [*callbacks, POSTHOG_CALLBACK]
|
||||||
|
|
||||||
|
# Check if this is a Claude model
|
||||||
|
if model_name.startswith("claude"):
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise RuntimeError("ANTHROPIC_API_KEY not set")
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"model": model_name,
|
||||||
|
"anthropic_api_key": ANTHROPIC_API_KEY,
|
||||||
|
"streaming": streaming,
|
||||||
|
}
|
||||||
|
if max_tokens is not None:
|
||||||
|
kwargs["max_tokens"] = max_tokens
|
||||||
|
if callbacks:
|
||||||
|
kwargs["callbacks"] = callbacks
|
||||||
|
|
||||||
|
return ChatAnthropic(**kwargs)
|
||||||
|
|
||||||
|
# OpenAI/GPT models
|
||||||
|
kwargs = {"model": model_name, "api_key": OPENAI_API_KEY, "streaming": streaming}
|
||||||
|
if max_tokens is not None:
|
||||||
|
kwargs["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
# Build GPT-5 specific parameters if not provided
|
||||||
|
if model_kwargs is None and model_name.startswith("gpt-5"):
|
||||||
|
# Determine which settings to use based on model
|
||||||
|
if model_name == SMART_MODEL:
|
||||||
|
reasoning_effort = SMART_MODEL_REASONING_EFFORT
|
||||||
|
text_verbosity = SMART_MODEL_TEXT_VERBOSITY
|
||||||
|
elif model_name == FAST_MODEL:
|
||||||
|
reasoning_effort = FAST_MODEL_REASONING_EFFORT
|
||||||
|
text_verbosity = FAST_MODEL_TEXT_VERBOSITY
|
||||||
|
else:
|
||||||
|
# Default for other GPT-5 variants
|
||||||
|
reasoning_effort = "medium"
|
||||||
|
text_verbosity = "medium"
|
||||||
|
|
||||||
|
# Pass as explicit parameters instead of model_kwargs to avoid warnings
|
||||||
|
kwargs["reasoning"] = {"effort": reasoning_effort}
|
||||||
|
kwargs["text"] = {"verbosity": text_verbosity}
|
||||||
|
elif model_kwargs:
|
||||||
|
# If custom model_kwargs provided, pass them through
|
||||||
|
kwargs["model_kwargs"] = model_kwargs
|
||||||
|
|
||||||
|
if callbacks:
|
||||||
|
kwargs["callbacks"] = callbacks
|
||||||
|
return ChatOpenAI(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OUTPUT_DIR",
|
||||||
|
"ASSETS_DIR",
|
||||||
|
"DATA_DIR",
|
||||||
|
"TEMPLATE_DIR",
|
||||||
|
"TEMPLATE_DB_PATH",
|
||||||
|
"VERSIONS_DB_PATH",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"OPENAI_BASE_URL",
|
||||||
|
"JAVA_BACKEND_URL",
|
||||||
|
"JAVA_BACKEND_API_KEY",
|
||||||
|
"JAVA_REQUEST_TIMEOUT_SECONDS",
|
||||||
|
"SMART_MODEL",
|
||||||
|
"get_chat_model",
|
||||||
|
"FAST_MODEL",
|
||||||
|
"SMART_MODEL_REASONING_EFFORT",
|
||||||
|
"FAST_MODEL_REASONING_EFFORT",
|
||||||
|
"SMART_MODEL_TEXT_VERBOSITY",
|
||||||
|
"FAST_MODEL_TEXT_VERBOSITY",
|
||||||
|
"FLASK_DEBUG",
|
||||||
|
"STREAMING_ENABLED",
|
||||||
|
"PREVIEW_MAX_INFLIGHT",
|
||||||
|
"AI_REQUEST_TIMEOUT_SECONDS",
|
||||||
|
"AI_RAW_DEBUG",
|
||||||
|
"AI_MESSAGES_LOG_PATH",
|
||||||
|
"POSTHOG_API_KEY",
|
||||||
|
"POSTHOG_HOST",
|
||||||
|
"POSTHOG_CLIENT",
|
||||||
|
"POSTHOG_CALLBACK",
|
||||||
|
"model_max_tokens",
|
||||||
|
]
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Advisor Agreement</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sub-section ── */
|
||||||
|
.legal-subsection {
|
||||||
|
margin-top: 6pt;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
margin-left: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-subsection-heading {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key-value rows (equity details) ── */
|
||||||
|
.kv-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-top: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-label {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.kv-row { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{AGREEMENT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">Advisor Agreement</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">{{AGREEMENT_REF}}</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">{{GOVERNING_LAW}}</span>
|
||||||
|
<span class="meta-label">Jurisdiction:</span> <span class="meta-value">{{JURISDICTION}}</span>
|
||||||
|
<span class="meta-label">Classification:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Company</div>
|
||||||
|
<div class="party-name">{{COMPANY_LEGAL_NAME}}</div>
|
||||||
|
<div class="party-detail">{{COMPANY_ENTITY_TYPE}}</div>
|
||||||
|
<div class="party-detail">Reg. No.: {{COMPANY_REG_NO}}</div>
|
||||||
|
<div class="party-detail">{{COMPANY_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Advisor</div>
|
||||||
|
<div class="party-name">{{ADVISOR_LEGAL_NAME}}</div>
|
||||||
|
<div class="party-detail">{{ADVISOR_ENTITY_TYPE}}</div>
|
||||||
|
<div class="party-detail">Reg. No.: {{ADVISOR_REG_NO}}</div>
|
||||||
|
<div class="party-detail">{{ADVISOR_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal sections -->
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Appointment</div>
|
||||||
|
<div class="legal-section-body">The Company appoints the Advisor to provide advisory services to the Company, and the Advisor accepts such appointment, subject to the terms of this Agreement.
|
||||||
|
|
||||||
|
<strong>Role:</strong> {{ADVISOR_ROLE_TITLE}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Services</div>
|
||||||
|
<div class="legal-section-body">{{SERVICES_DESCRIPTION_TEXT}}</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.1 Duties</div>
|
||||||
|
<div class="legal-section-body">{{ADVISOR_DUTIES_ITEMS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.2 Communications</div>
|
||||||
|
<div class="legal-section-body">{{MEETING_CADENCE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Term</div>
|
||||||
|
<div class="legal-section-body">{{TERM_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Compensation</div>
|
||||||
|
<div class="legal-section-body">{{COMPENSATION_OVERVIEW_TEXT}}</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">4.1 Cash Compensation</div>
|
||||||
|
<div class="legal-section-body">{{CASH_COMPENSATION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">4.2 Equity Compensation</div>
|
||||||
|
<div class="legal-section-body">{{EQUITY_COMPENSATION_TEXT}}</div>
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">Equity type:</span> <span class="kv-value">{{EQUITY_TYPE_TEXT}}</span>
|
||||||
|
<span class="kv-label">Equity amount:</span> <span class="kv-value">{{EQUITY_AMOUNT_TEXT}}</span>
|
||||||
|
<span class="kv-label">Exercise / issue price:</span> <span class="kv-value">{{EQUITY_EXERCISE_PRICE_TEXT}}</span>
|
||||||
|
<span class="kv-label">Vesting:</span> <span class="kv-value">{{VESTING_TEXT}}</span>
|
||||||
|
<span class="kv-label">Post-termination exercise:</span> <span class="kv-value">{{POST_TERMINATION_EXERCISE_TEXT}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">4.3 Expenses</div>
|
||||||
|
<div class="legal-section-body">{{EXPENSES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">{{CONFIDENTIALITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Intellectual Property and Work Product</div>
|
||||||
|
<div class="legal-section-body">{{IP_ASSIGNMENT_TEXT}}</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">6.1 Works Made for Hire</div>
|
||||||
|
<div class="legal-section-body">{{WORKS_MADE_FOR_HIRE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Conflicts of Interest</div>
|
||||||
|
<div class="legal-section-body">{{CONFLICTS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Non-Solicitation</div>
|
||||||
|
<div class="legal-section-body">{{NON_SOLICIT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Independent Contractor Status</div>
|
||||||
|
<div class="legal-section-body">{{INDEPENDENT_STATUS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. Warranties</div>
|
||||||
|
<div class="legal-section-body">{{WARRANTY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">11. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">{{LIABILITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">12. Indemnity</div>
|
||||||
|
<div class="legal-section-body">{{INDEMNITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">13. Termination</div>
|
||||||
|
<div class="legal-section-body">{{TERMINATION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">14. Notices</div>
|
||||||
|
<div class="legal-section-body">{{NOTICES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">15. Assignment</div>
|
||||||
|
<div class="legal-section-body">{{ASSIGNMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">16. Amendments</div>
|
||||||
|
<div class="legal-section-body">{{AMENDMENTS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">17. Entire Agreement</div>
|
||||||
|
<div class="legal-section-body">{{ENTIRE_AGREEMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules -->
|
||||||
|
<div class="section-heading">Schedules</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-body">{{SCHEDULES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have caused this Agreement to be executed by their duly authorised representatives.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Company</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: {{COMPANY_SIGNATORY}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Advisor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: {{ADVISOR_SIGNATORY}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Advisor Agreement — Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { text-align: center; margin-bottom: 8pt; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 130pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.legal-subsection { margin-top: 6pt; margin-bottom: 6pt; margin-left: 10pt; }
|
||||||
|
.legal-subsection-heading { font-size: 9pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.kv-row { display: grid; grid-template-columns: 130pt auto; row-gap: 2pt; margin-top: 4pt; }
|
||||||
|
.kv-label { font-size: 8.5pt; font-weight: 600; color: var(--theme-text-muted); }
|
||||||
|
.kv-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-title">Advisor Agreement</div>
|
||||||
|
<div class="doc-subtitle">Advisor Agreement</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">Ref: ADV-2026-01</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">2 February 2026</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">England and Wales</span>
|
||||||
|
<span class="meta-label">Jurisdiction:</span> <span class="meta-value">Courts of England and Wales</span>
|
||||||
|
<span class="meta-label">Classification:</span> <span class="meta-value">Confidential</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Company</div>
|
||||||
|
<div class="party-name">Sterling Systems Group Ltd</div>
|
||||||
|
<div class="party-detail">A private limited company</div>
|
||||||
|
<div class="party-detail">Reg. No.: 12345678</div>
|
||||||
|
<div class="party-detail">100 Kingfisher Way, London, EC2V 7AB, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Advisor</div>
|
||||||
|
<div class="party-name">Harbourview Strategy Partners Ltd</div>
|
||||||
|
<div class="party-detail">A private limited company</div>
|
||||||
|
<div class="party-detail">Reg. No.: 87654321</div>
|
||||||
|
<div class="party-detail">8 Riverside Walk, London, SE1 9AB, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Appointment</div>
|
||||||
|
<div class="legal-section-body">The Company appoints the Advisor to provide advisory services to the Company, and the Advisor accepts such appointment, subject to the terms of this Agreement.
|
||||||
|
|
||||||
|
<strong>Role:</strong> Strategic Advisor — Go-to-Market and Partnerships</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Services</div>
|
||||||
|
<div class="legal-section-body">The Advisor will provide strategic guidance and introductions to support the Company's go-to-market execution and partnership development. The Advisor will not have authority to bind the Company and will provide services in an advisory capacity only.</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.1 Duties</div>
|
||||||
|
<div class="legal-section-body">Provide strategic input on target segments, pricing, and positioning.
|
||||||
|
Facilitate introductions to prospective partners and key customers, where appropriate.
|
||||||
|
Review major partnership proposals and provide feedback on terms and structure.
|
||||||
|
Participate in periodic advisory calls and ad hoc consultations as reasonably requested.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.2 Communications</div>
|
||||||
|
<div class="legal-section-body">The parties anticipate one scheduled call per month (30–60 minutes), plus reasonable email support, with additional sessions by mutual agreement.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Term</div>
|
||||||
|
<div class="legal-section-body">This Agreement commences on the Effective Date and continues for twelve (12) months unless earlier terminated in accordance with Section 13. The parties may extend the term by written agreement.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Compensation</div>
|
||||||
|
<div class="legal-section-body">In consideration for the Services, the Company will provide the compensation described below, subject to the terms of this Agreement and any applicable equity plan documents.</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">4.1 Cash Compensation</div>
|
||||||
|
<div class="legal-section-body">No cash compensation will be paid unless agreed in writing in advance for specific projects.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">4.2 Equity Compensation</div>
|
||||||
|
<div class="legal-section-body">The Company will grant the Advisor an equity award under the Company's equity incentive plan, subject to board/committee approval and the definitive equity grant documentation.</div>
|
||||||
|
<div class="kv-row">
|
||||||
|
<span class="kv-label">Equity type:</span> <span class="kv-value">Non-qualified stock options (or equivalent plan award)</span>
|
||||||
|
<span class="kv-label">Equity amount:</span> <span class="kv-value">Options representing 0.25% of fully diluted share capital (on grant date basis)</span>
|
||||||
|
<span class="kv-label">Exercise / issue price:</span> <span class="kv-value">Exercise price per the plan / fair market value on grant date</span>
|
||||||
|
<span class="kv-label">Vesting:</span> <span class="kv-value">24 months vesting with monthly vesting, subject to continued service as advisor</span>
|
||||||
|
<span class="kv-label">Post-termination exercise:</span> <span class="kv-value">90 days to exercise vested options following termination (unless plan states otherwise)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">4.3 Expenses</div>
|
||||||
|
<div class="legal-section-body">Reasonable, pre-approved out-of-pocket expenses will be reimbursed at cost upon receipt of valid supporting documentation.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">Each party shall keep confidential all non-public information disclosed by the other in connection with this Agreement and shall use such information solely to perform its obligations or exercise its rights under this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Intellectual Property and Work Product</div>
|
||||||
|
<div class="legal-section-body">To the extent the Advisor creates any work product, inventions, discoveries, or materials specifically for the Company in the course of providing the Services ("Work Product"), the Advisor hereby assigns to the Company all right, title, and interest in such Work Product upon creation, subject to any pre-existing materials retained by the Advisor.</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">6.1 Works Made for Hire</div>
|
||||||
|
<div class="legal-section-body">Where permitted by law, Work Product shall be deemed "works made for hire" for the Company. To the extent not so deemed, the Advisor assigns all rights in and to the Work Product to the Company as set out above.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Conflicts of Interest</div>
|
||||||
|
<div class="legal-section-body">The Advisor will avoid conflicts of interest and will promptly disclose any actual or potential conflict relating to the Services. The Advisor will not use the Company's confidential information to benefit any third party.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Non-Solicitation</div>
|
||||||
|
<div class="legal-section-body">During the term of this Agreement and for six (6) months thereafter, the Advisor shall not knowingly solicit for employment any employee of the Company with whom the Advisor had material contact in connection with the Services, except through general advertisements not targeted at such employees.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Independent Contractor Status</div>
|
||||||
|
<div class="legal-section-body">The Advisor is an independent contractor and not an employee, worker, agent, or partner of the Company. The Advisor is responsible for all taxes and statutory obligations arising from compensation under this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. Warranties</div>
|
||||||
|
<div class="legal-section-body">The Advisor warrants that the Services will be performed with reasonable skill and care. Except as expressly stated, all warranties are excluded to the fullest extent permitted by law.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">11. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">Neither party shall be liable for indirect or consequential damages. Each party's aggregate liability arising out of or in connection with this Agreement shall not exceed the total value of compensation paid or payable under this Agreement, except where liability cannot be limited by law.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">12. Indemnity</div>
|
||||||
|
<div class="legal-section-body">Each party will indemnify the other against third-party claims arising from its gross negligence or wilful misconduct in connection with this Agreement, subject to the limitation of liability set out herein.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">13. Termination</div>
|
||||||
|
<div class="legal-section-body">Either party may terminate this Agreement for convenience upon fourteen (14) days' written notice. Either party may terminate immediately for material breach if not cured within fourteen (14) days of written notice. Upon termination, unvested equity (if any) will cease to vest in accordance with the applicable plan and grant documents.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">14. Notices</div>
|
||||||
|
<div class="legal-section-body">Notices must be in writing and delivered by email and/or registered post to the addresses set out in the Parties section (or as updated by notice).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">15. Assignment</div>
|
||||||
|
<div class="legal-section-body">Neither party may assign this Agreement without the prior written consent of the other, except to an affiliate or in connection with a merger or sale of substantially all assets.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">16. Amendments</div>
|
||||||
|
<div class="legal-section-body">Any amendment to this Agreement must be in writing and signed by both parties.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">17. Entire Agreement</div>
|
||||||
|
<div class="legal-section-body">This Agreement constitutes the entire agreement between the parties regarding its subject matter and supersedes all prior discussions and understandings.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Schedules</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-body">Schedule 1 — Scope of Services and Deliverables (if applicable)
|
||||||
|
Schedule 2 — Equity Award Details (plan, grant date, vesting, and exercise terms)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have caused this Agreement to be executed by their duly authorised representatives.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Company</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: Alexandra Moore, Director</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 2 February 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Advisor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: Priya Shah, Director</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 2 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Audit Report</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Findings table ── */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Risk level badges ── */
|
||||||
|
.risk-high {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
padding: 1pt 5pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-medium {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
padding: 1pt 5pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-low {
|
||||||
|
display: inline-block;
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
padding: 1pt 5pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sign-off ── */
|
||||||
|
.signoff-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.data-table tbody tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.signoff-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
.data-table tbody tr { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{REPORT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}} · Ref: {{AUDIT_REF}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Audit Period:</span> <span class="meta-value">{{AUDIT_PERIOD}}</span>
|
||||||
|
<span class="meta-label">Prepared By:</span> <span class="meta-value">{{PREPARED_BY}}</span>
|
||||||
|
<span class="meta-label">Report Date:</span> <span class="meta-value">{{REPORT_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Executive Summary -->
|
||||||
|
<div class="section-heading">Executive Summary</div>
|
||||||
|
<div class="text-section">{{EXECUTIVE_SUMMARY_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Scope and Objectives -->
|
||||||
|
<div class="section-heading">Scope and Objectives</div>
|
||||||
|
<div class="text-section">{{SCOPE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Methodology -->
|
||||||
|
<div class="section-heading">Methodology</div>
|
||||||
|
<div class="text-section">{{METHODOLOGY_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Findings -->
|
||||||
|
<div class="section-heading">Findings</div>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:5%">#</th>
|
||||||
|
<th style="width:30%">Finding</th>
|
||||||
|
<th style="width:12%">Risk Level</th>
|
||||||
|
<th style="width:25%">Recommendation</th>
|
||||||
|
<th style="width:28%">Management Response</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{FINDINGS_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Conclusion -->
|
||||||
|
<div class="section-heading">Conclusion</div>
|
||||||
|
<div class="text-section">{{CONCLUSION_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Sign-off -->
|
||||||
|
<div class="section-heading">Sign-Off</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{SIGN_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Lead Auditor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{AUDITOR_NAME}}</div>
|
||||||
|
<div class="sig-field-label">{{AUDITOR_TITLE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Reviewed By</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Authorised Signatory</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Audit Report</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.6; margin-bottom: 4pt; }
|
||||||
|
.findings-table { width: 100%; border-collapse: collapse; margin-bottom: 8pt; font-size: 8pt; }
|
||||||
|
.findings-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.findings-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.findings-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.findings-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.risk-high { display: inline-block; background: #fee2e2; color: #991b1b; font-weight: 700; font-size: 7.5pt; padding: 1pt 5pt; border-radius: 2pt; white-space: nowrap; }
|
||||||
|
.risk-medium { display: inline-block; background: #fef3c7; color: #92400e; font-weight: 700; font-size: 7.5pt; padding: 1pt 5pt; border-radius: 2pt; white-space: nowrap; }
|
||||||
|
.risk-low { display: inline-block; background: #dcfce7; color: #166534; font-weight: 700; font-size: 7.5pt; padding: 1pt 5pt; border-radius: 2pt; white-space: nowrap; }
|
||||||
|
.signoff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 14pt; }
|
||||||
|
.signoff-col { font-size: 8.5pt; }
|
||||||
|
.signoff-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 18pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .findings-table tbody tr { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Audit Report</div>
|
||||||
|
<div class="doc-subtitle">Vantage Capital Partners Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Audit Period:</span> <span class="meta-value">1 October 2025 – 31 January 2026</span>
|
||||||
|
<span class="meta-label">Prepared By:</span> <span class="meta-value">Dominic Hartley, Head of Internal Audit</span>
|
||||||
|
<span class="meta-label">Report Date:</span> <span class="meta-value">14 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Executive Summary</div>
|
||||||
|
<div class="text-section">This internal audit was conducted to assess the effectiveness of Vantage Capital Partners' procurement and supplier management framework across all UK business units. The audit covered the period 1 October 2025 to 31 January 2026 and encompassed purchase order processes, supplier onboarding controls, contract management and invoice approval workflows.
|
||||||
|
|
||||||
|
Overall, the procurement function operates with a reasonable control environment. However, the audit identified three significant findings and two areas of improvement, as detailed below. Management has acknowledged the findings and proposed remediation actions with target completion dates. The overall audit opinion is Partially Satisfactory.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Scope and Objectives</div>
|
||||||
|
<div class="text-section">The audit was scoped to cover: (1) purchase order authorisation and segregation of duties; (2) supplier due diligence and onboarding procedures; (3) contract register completeness and renewal monitoring; (4) three-way matching of purchase orders, delivery notes and invoices; and (5) compliance with the Group Procurement Policy (v3.1, updated September 2025).
|
||||||
|
|
||||||
|
Out of scope: IT systems procurement governed by the Technology Steering Committee and capital expenditure projects above £500,000 (subject to separate Board approval processes).
|
||||||
|
|
||||||
|
The objectives were to provide assurance that procurement activities are conducted with appropriate controls, value for money is achieved, and risks of fraud and error are adequately mitigated.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Methodology</div>
|
||||||
|
<div class="text-section">The audit was conducted in accordance with the Chartered Institute of Internal Auditors (CIIA) Standards. Fieldwork comprised: structured interviews with the Head of Finance Operations, two Procurement Managers and the Legal Counsel; walkthrough testing of the purchase-to-pay process; review of a random sample of 45 purchase orders raised between October 2025 and January 2026; analysis of the supplier register and contract database; and examination of invoice approval audit logs from the Oracle Fusion ERP system.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Findings</div>
|
||||||
|
<table class="findings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:5%">#</th>
|
||||||
|
<th style="width:30%">Finding</th>
|
||||||
|
<th style="width:12%">Risk Level</th>
|
||||||
|
<th style="width:25%">Recommendation</th>
|
||||||
|
<th style="width:28%">Management Response</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>11 of 45 purchase orders sampled (24%) lacked a second approver for values between £25,000–£100,000, contrary to the Group Procurement Policy requirement for dual authorisation above £10,000.</td>
|
||||||
|
<td><span class="risk-high">High</span></td>
|
||||||
|
<td>Enforce dual-authorisation controls within Oracle Fusion for all POs above £10,000 and implement automated escalation alerts for non-compliance.</td>
|
||||||
|
<td>Agreed. Finance Operations will reconfigure Oracle Fusion approval workflows by 31 March 2026. A mandatory awareness communication will be issued to all budget holders by 28 February 2026. Owner: Head of Finance Operations.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>The supplier register contained 38 active suppliers with expired due diligence documentation (company credit checks and Modern Slavery Act questionnaires older than 24 months).</td>
|
||||||
|
<td><span class="risk-high">High</span></td>
|
||||||
|
<td>Implement an annual supplier re-accreditation cycle and introduce automated expiry alerts within the supplier management module.</td>
|
||||||
|
<td>Agreed. Procurement will conduct a full supplier re-accreditation exercise by 30 April 2026. Automated alerts will be configured in the ERP system by 31 March 2026. Owner: Group Procurement Manager.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>17 contracts (22% of the active contract register) had no documented renewal or expiry review scheduled, creating a risk of inadvertent auto-renewal on unfavourable terms.</td>
|
||||||
|
<td><span class="risk-medium">Medium</span></td>
|
||||||
|
<td>Assign a contract owner to each active contract and establish a 90-day advance review process within the contract management system.</td>
|
||||||
|
<td>Agreed. Legal Counsel will update the contract register and assign ownership by 31 March 2026. A 90-day review process will be embedded in the contract management system by 30 April 2026. Owner: Legal Counsel.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>4</td>
|
||||||
|
<td>Invoice coding errors were identified in 6% of sampled invoices, with amounts incorrectly posted to cost centres due to the absence of a mandatory cost-centre validation rule in Oracle Fusion.</td>
|
||||||
|
<td><span class="risk-low">Low</span></td>
|
||||||
|
<td>Introduce a mandatory cost-centre validation rule at invoice entry stage and provide refresher training to accounts payable staff.</td>
|
||||||
|
<td>Agreed. IT will implement the validation rule by 28 February 2026. Training sessions will be arranged for accounts payable by 14 March 2026. Owner: Head of Finance Operations.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Conclusion</div>
|
||||||
|
<div class="text-section">The audit concludes that the procurement and supplier management function operates with a partially satisfactory control environment. The two high-risk findings relating to purchase order authorisation and supplier due diligence represent the most significant risks and require prompt remediation. Management has responded constructively and the agreed actions, if implemented to the stated deadlines, should substantially improve the control framework. Internal Audit will conduct a follow-up review in July 2026 to verify implementation of all agreed actions.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Sign-Off</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">14 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Lead Auditor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Dominic Hartley</div>
|
||||||
|
<div class="sig-field-label">Head of Internal Audit</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Reviewed By</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Authorised Signatory</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Board Minutes</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attendance tables ── */
|
||||||
|
.attendance-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-table thead tr {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-table thead th {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-border, #e2e8f0);
|
||||||
|
font-size: 8pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-table tbody td {
|
||||||
|
padding: 3pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agenda items ── */
|
||||||
|
.agenda-item {
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
padding-left: 8pt;
|
||||||
|
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution-box {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
margin-top: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Resolutions summary table ── */
|
||||||
|
.resolutions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolutions-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolutions-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolutions-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolutions-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sign-off ── */
|
||||||
|
.signoff-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.agenda-item { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.signoff-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.resolutions-table tbody tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
.agenda-item { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{DOC_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}} · Ref: {{DOC_REF}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{MEETING_DATE}}</span>
|
||||||
|
<span class="meta-label">Time:</span> <span class="meta-value">{{MEETING_TIME}}</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">{{MEETING_LOCATION}}</span>
|
||||||
|
<span class="meta-label">Chair:</span> <span class="meta-value">{{CHAIR_NAME}}</span>
|
||||||
|
<span class="meta-label">Secretary:</span> <span class="meta-value">{{SECRETARY_NAME}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Present -->
|
||||||
|
<div class="section-heading">Attendance</div>
|
||||||
|
<table class="attendance-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Name</th>
|
||||||
|
<th style="width:35%">Title / Role</th>
|
||||||
|
<th style="width:25%">Organisation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{PRESENT_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Apologies -->
|
||||||
|
<div class="section-heading">Apologies</div>
|
||||||
|
<table class="attendance-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Name</th>
|
||||||
|
<th style="width:60%">Title / Role</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{APOLOGIES_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Matters and Business -->
|
||||||
|
<div class="section-heading">Matters and Business of the Meeting</div>
|
||||||
|
{{AGENDA_ITEMS}}
|
||||||
|
|
||||||
|
<!-- Resolutions Summary -->
|
||||||
|
<div class="section-heading">Resolutions Summary</div>
|
||||||
|
<table class="resolutions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:12%">Ref</th>
|
||||||
|
<th style="width:60%">Resolution</th>
|
||||||
|
<th style="width:28%">Carried By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{RESOLUTIONS_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Approval -->
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<p style="font-size:8.5pt; margin-bottom:10pt;">These minutes are a true and accurate record of the proceedings of the above meeting.</p>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date Approved:</span> <span class="meta-value">{{APPROVAL_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Chair</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{CHAIR_SIGN_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Secretary</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{SECRETARY_SIGN_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Board Minutes</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.attendance-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.attendance-table thead tr { background: var(--theme-surface); }
|
||||||
|
.attendance-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-border); font-size: 8pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.attendance-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); }
|
||||||
|
.agenda-item { margin-bottom: 10pt; padding-left: 8pt; border-left: 2pt solid var(--theme-accent); page-break-inside: avoid; }
|
||||||
|
.agenda-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.agenda-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 4pt; }
|
||||||
|
.resolution-box { font-size: 8.5pt; font-weight: 600; color: var(--theme-primary); background: var(--theme-surface); border: 0.5pt solid var(--theme-border); padding: 4pt 6pt; margin-top: 4pt; }
|
||||||
|
.resolutions-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.resolutions-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.resolutions-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.resolutions-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.resolutions-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.signoff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 14pt; }
|
||||||
|
.signoff-col { font-size: 8.5pt; }
|
||||||
|
.signoff-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 18pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .agenda-item { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Board Minutes</div>
|
||||||
|
<div class="doc-subtitle">Hartwell Group plc</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">12 March 2026</span>
|
||||||
|
<span class="meta-label">Time:</span> <span class="meta-value">09:00 – 11:30</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">Boardroom, 14 Cavendish Square, London W1G 0PH</span>
|
||||||
|
<span class="meta-label">Chair:</span> <span class="meta-value">Dame Catherine Ashworth, Non-Executive Chair</span>
|
||||||
|
<span class="meta-label">Secretary:</span> <span class="meta-value">Richard Tewkesbury, Company Secretary</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Attendance</div>
|
||||||
|
<table class="attendance-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Name</th>
|
||||||
|
<th style="width:35%">Title / Role</th>
|
||||||
|
<th style="width:25%">Organisation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Dame Catherine Ashworth</td><td>Non-Executive Chair</td><td>Hartwell Group plc</td></tr>
|
||||||
|
<tr><td>Jonathan Pemberton</td><td>Chief Executive Officer</td><td>Hartwell Group plc</td></tr>
|
||||||
|
<tr><td>Fiona Caldecott</td><td>Chief Financial Officer</td><td>Hartwell Group plc</td></tr>
|
||||||
|
<tr><td>Marcus Osei-Bonsu</td><td>Chief Operating Officer</td><td>Hartwell Group plc</td></tr>
|
||||||
|
<tr><td>Dr Priya Venkataraman</td><td>Non-Executive Director</td><td>Hartwell Group plc</td></tr>
|
||||||
|
<tr><td>Alastair Drummond</td><td>Senior Independent Director</td><td>Hartwell Group plc</td></tr>
|
||||||
|
<tr><td>Richard Tewkesbury</td><td>Company Secretary</td><td>Hartwell Group plc</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Apologies</div>
|
||||||
|
<table class="attendance-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Name</th>
|
||||||
|
<th style="width:60%">Title / Role</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Helena Forsythe</td><td>Non-Executive Director</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Matters and Business of the Meeting</div>
|
||||||
|
|
||||||
|
<div class="agenda-item">
|
||||||
|
<div class="agenda-heading">1. Apologies and Conflicts of Interest</div>
|
||||||
|
<div class="agenda-body">The Chair noted apologies received from Helena Forsythe. No conflicts of interest were declared with respect to items on the agenda. The Chair confirmed that a quorum was present and the meeting was duly constituted.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agenda-item">
|
||||||
|
<div class="agenda-heading">2. Minutes of Previous Meeting</div>
|
||||||
|
<div class="agenda-body">The minutes of the Board meeting held on 12 February 2026 (Ref: BM-2026-02) were reviewed and confirmed as a true and accurate record.</div>
|
||||||
|
<div class="resolution-box">Resolution: The Board approved the minutes of the meeting held on 12 February 2026 as a true and accurate record. Proposed by J. Pemberton; seconded by F. Caldecott. Carried unanimously.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agenda-item">
|
||||||
|
<div class="agenda-heading">3. Chief Executive's Report</div>
|
||||||
|
<div class="agenda-body">Jonathan Pemberton presented the CEO's report for the period ending 28 February 2026. Revenue performance was 4.2% ahead of budget, driven by stronger-than-anticipated demand in the Infrastructure Services division. The Board noted ongoing recruitment challenges in the Technology division and requested a detailed workforce plan be presented at the April meeting. A tender submission for the Northern Rail Framework contract, valued at approximately £42m over four years, was confirmed as proceeding on schedule.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agenda-item">
|
||||||
|
<div class="agenda-heading">4. Financial Performance and Treasury Update</div>
|
||||||
|
<div class="agenda-body">Fiona Caldecott presented the management accounts for February 2026. Group EBITDA stood at £8.3m against a budget of £7.9m. Net debt reduced to £14.1m, reflecting strong cash conversion. The Board approved the revised cash flow forecast for Q2 2026. Fiona noted that the revolving credit facility renewal negotiations with Barclays were progressing well and heads of terms were expected by end of March.</div>
|
||||||
|
<div class="resolution-box">Resolution: The Board approved the revised Q2 2026 cash flow forecast as presented. Carried unanimously.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agenda-item">
|
||||||
|
<div class="agenda-heading">5. Any Other Business</div>
|
||||||
|
<div class="agenda-body">Alastair Drummond raised the matter of the forthcoming AGM scheduled for 22 May 2026. Richard Tewkesbury confirmed that notice documents are on track for dispatch by 10 April 2026. The Chair thanked all Directors for their contributions and confirmed the next scheduled Board meeting.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Resolutions Summary</div>
|
||||||
|
<table class="resolutions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:12%">Ref</th>
|
||||||
|
<th style="width:60%">Resolution</th>
|
||||||
|
<th style="width:28%">Carried By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>R-01</td><td>Approval of minutes of meeting held 12 February 2026</td><td>Unanimous</td></tr>
|
||||||
|
<tr><td>R-02</td><td>Approval of revised Q2 2026 cash flow forecast</td><td>Unanimous</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<p style="font-size:8.5pt; margin-bottom:10pt;">These minutes are a true and accurate record of the proceedings of the above meeting.</p>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date Approved:</span> <span class="meta-value">9 April 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Chair</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Dame Catherine Ashworth</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Secretary</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Richard Tewkesbury</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Budget Proposal</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin: 8pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Net summary ── */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
min-width: 160pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-top: 4pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .net-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Approval block ── */
|
||||||
|
.approval-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 3pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.approval-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{PROPOSAL_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<div class="info-label">Budget Period</div>
|
||||||
|
<div class="info-value">{{BUDGET_PERIOD}}</div>
|
||||||
|
<div class="info-label" style="margin-top:4pt">Prepared By</div>
|
||||||
|
<div class="info-value">{{PREPARED_BY}}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="info-label">Prepared Date</div>
|
||||||
|
<div class="info-value">{{PREPARED_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Executive Summary -->
|
||||||
|
<div class="section-heading">Executive Summary</div>
|
||||||
|
<div class="text-section">{{EXECUTIVE_SUMMARY_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Revenue Projections -->
|
||||||
|
<div class="section-heading">Revenue Projections</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Revenue Stream</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num" style="width:60pt">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{REVENUE_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="total-row"><td>Total Revenue:</td><td>{{TOTAL_REVENUE}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expense Breakdown -->
|
||||||
|
<div class="section-heading">Expense Breakdown</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num" style="width:60pt">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{EXPENSE_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="total-row"><td>Total Expenses:</td><td>{{TOTAL_EXPENSES}}</td></tr>
|
||||||
|
<tr class="net-row"><td>Net Surplus / (Deficit):</td><td>{{NET_SURPLUS}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assumptions -->
|
||||||
|
<div class="section-heading">Assumptions</div>
|
||||||
|
<div class="text-section">{{ASSUMPTIONS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Approval -->
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<div class="approval-grid">
|
||||||
|
<span class="approval-label">Approver Name:</span> <span class="approval-value">{{APPROVER_NAME}}</span>
|
||||||
|
<span class="approval-label">Approver Title:</span> <span class="approval-value">{{APPROVER_TITLE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Budget Proposal</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin: 8pt 0; }
|
||||||
|
.info-label { font-weight: 600; font-size: 8.5pt; color: var(--theme-text-muted); }
|
||||||
|
.info-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 10pt; }
|
||||||
|
.totals-table { border-collapse: collapse; font-size: 8.5pt; min-width: 160pt; }
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||||
|
.totals-table td:last-child { text-align: right; }
|
||||||
|
.totals-table .total-row td { font-weight: 700; font-size: 10pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; color: var(--theme-heading); }
|
||||||
|
.totals-table .net-row td { font-weight: 700; font-size: 10pt; color: var(--theme-accent); }
|
||||||
|
.approval-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 3pt; margin-bottom: 8pt; }
|
||||||
|
.approval-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.approval-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Budget Proposal</div>
|
||||||
|
<div class="doc-subtitle">Crestwood Technologies Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<div class="info-label">Budget Period</div>
|
||||||
|
<div class="info-value">1 April 2026 – 31 March 2027</div>
|
||||||
|
<div class="info-label" style="margin-top:4pt">Prepared By</div>
|
||||||
|
<div class="info-value">Rachel Thornton, Head of Finance</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="info-label">Prepared Date</div>
|
||||||
|
<div class="info-value">19 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Executive Summary</div>
|
||||||
|
<div class="text-section">This proposal outlines the projected revenue and expenditure for the Product Engineering Division for the financial year ending 31 March 2027. Following strong performance in FY 2025/26, the division is seeking approval for an 18% increase in operating budget to accelerate product development, expand the engineering team, and invest in cloud infrastructure to support anticipated customer growth of 35%.
|
||||||
|
|
||||||
|
The net surplus projection of £412,000 reflects disciplined cost management alongside planned investment in headcount and tooling. All figures are presented in GBP and have been reviewed by the CFO.</div>
|
||||||
|
<div class="section-heading">Revenue Projections</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Revenue Stream</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num" style="width:60pt">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>SaaS Subscriptions</td>
|
||||||
|
<td>Recurring licence revenue — existing & new customers</td>
|
||||||
|
<td class="num">£2,800,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Professional Services</td>
|
||||||
|
<td>Implementation, training, and consultancy</td>
|
||||||
|
<td class="num">£480,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Marketplace Add-ons</td>
|
||||||
|
<td>Third-party integrations and premium modules</td>
|
||||||
|
<td class="num">£165,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Support Contracts</td>
|
||||||
|
<td>Priority and enterprise support tiers</td>
|
||||||
|
<td class="num">£95,000</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="total-row"><td>Total Revenue:</td><td>£3,540,000</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Expense Breakdown</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num" style="width:60pt">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Headcount</td>
|
||||||
|
<td>Salaries, NI, and pension — 34 FTEs (incl. 4 new hires)</td>
|
||||||
|
<td class="num">£1,980,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cloud Infrastructure</td>
|
||||||
|
<td>AWS hosting, storage, and data services</td>
|
||||||
|
<td class="num">£420,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Software & Tooling</td>
|
||||||
|
<td>Dev tools, SaaS licences, security platforms</td>
|
||||||
|
<td class="num">£215,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Marketing</td>
|
||||||
|
<td>Demand generation, events, content production</td>
|
||||||
|
<td class="num">£180,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>T&E</td>
|
||||||
|
<td>Business travel, client entertainment, conferences</td>
|
||||||
|
<td class="num">£88,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Contingency</td>
|
||||||
|
<td>5% operational reserve</td>
|
||||||
|
<td class="num">£165,000</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="total-row"><td>Total Expenses:</td><td>£3,048,000</td></tr>
|
||||||
|
<tr class="net-row"><td>Net Surplus / (Deficit):</td><td>£492,000</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Assumptions</div>
|
||||||
|
<div class="text-section">1. Revenue growth of 35% year-on-year is based on confirmed pipeline and contracted renewals constituting 72% of the total revenue target.
|
||||||
|
2. Four engineering hires are assumed to be on-boarded by Q1 FY 2026/27; salaries reflect current market rates for mid-senior software engineers in the UK.
|
||||||
|
3. AWS costs include a committed-use discount of 18% negotiated under the existing enterprise agreement.
|
||||||
|
4. All figures exclude capital expenditure, which is budgeted separately under the IT CapEx programme.
|
||||||
|
5. Exchange rate risk on USD-denominated tooling licences is hedged at the treasury level and not reflected here.</div>
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<div class="approval-grid">
|
||||||
|
<span class="approval-label">Approver Name:</span> <span class="approval-value">Jonathan Crestwood</span>
|
||||||
|
<span class="approval-label">Approver Title:</span> <span class="approval-value">Chief Financial Officer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Case Study</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80pt auto;
|
||||||
|
row-gap: 4pt;
|
||||||
|
margin: 10pt 0;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-bottom: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
padding: 8pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body text ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Testimonial block ── */
|
||||||
|
.testimonial-block {
|
||||||
|
border-left: 3pt solid var(--theme-accent, #2563eb);
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
padding: 8pt 10pt;
|
||||||
|
margin: 6pt 0;
|
||||||
|
border-radius: 0 3pt 3pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testimonial-quote {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testimonial-attribution {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.testimonial-block { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{CASE_STUDY_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">Case Study</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Client:</span> <span class="meta-value">{{CLIENT_NAME}}</span>
|
||||||
|
<span class="meta-label">Industry:</span> <span class="meta-value">{{INDUSTRY}}</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Overview -->
|
||||||
|
<div class="section-heading">Client Overview</div>
|
||||||
|
<div class="text-section">{{CLIENT_OVERVIEW_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Challenge -->
|
||||||
|
<div class="section-heading">Challenge</div>
|
||||||
|
<div class="text-section">{{CHALLENGE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Solution -->
|
||||||
|
<div class="section-heading">Solution</div>
|
||||||
|
<div class="text-section">{{SOLUTION_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Implementation -->
|
||||||
|
<div class="section-heading">Implementation</div>
|
||||||
|
<div class="text-section">{{IMPLEMENTATION_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="section-heading">Results</div>
|
||||||
|
<div class="text-section">{{RESULTS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Testimonial -->
|
||||||
|
<div class="section-heading">Client Testimonial</div>
|
||||||
|
<div class="testimonial-block">
|
||||||
|
<div class="testimonial-quote">{{TESTIMONIAL_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conclusion -->
|
||||||
|
<div class="section-heading">Conclusion</div>
|
||||||
|
<div class="text-section">{{CONCLUSION_TEXT}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Case Study</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 80pt auto; row-gap: 4pt; margin: 10pt 0; border-top: 0.5pt solid var(--theme-border); border-bottom: 0.5pt solid var(--theme-border); padding: 8pt 0; }
|
||||||
|
.meta-label { font-weight: 700; font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.meta-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.testimonial-block { border-left: 3pt solid var(--theme-accent); background: var(--theme-surface); padding: 8pt 10pt; margin: 6pt 0; border-radius: 0 3pt 3pt 0; }
|
||||||
|
.testimonial-quote { font-size: 9pt; font-style: italic; color: var(--theme-text); line-height: 1.6; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.testimonial-attribution { font-size: 8pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Case Study</div>
|
||||||
|
<div class="doc-subtitle">Stirling PDF</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div style="font-size: 11pt; font-weight: 600; color: var(--theme-heading); margin: 8pt 0 10pt;">How Nexus Cloud Reduced Invoice Processing Time by 74%</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Client:</span> <span class="meta-value">Nexus Cloud Ltd</span>
|
||||||
|
<span class="meta-label">Industry:</span> <span class="meta-value">Financial Technology (FinTech)</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">February 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Client Overview</div>
|
||||||
|
<div class="text-section">Nexus Cloud Ltd is a London-based financial technology company providing accounts-payable automation software to mid-market businesses across the UK and Ireland. Founded in 2017, the company employs 210 staff and processes over 4 million invoices annually on behalf of its 320 enterprise clients. Following a Series B funding round in 2024, Nexus Cloud entered a rapid growth phase, scaling its customer base by 45% in eighteen months.</div>
|
||||||
|
<div class="section-heading">Challenge</div>
|
||||||
|
<div class="text-section">Rapid customer growth exposed significant bottlenecks in Nexus Cloud's internal document management and PDF generation infrastructure. The legacy system — a patchwork of third-party libraries and manual export workflows — was unable to scale with demand. Key problems included:
|
||||||
|
|
||||||
|
- Invoice and statement generation times averaging 8.2 seconds per document, causing SLA breaches during peak periods
|
||||||
|
- No support for branded, client-configurable document templates, resulting in high-value customers receiving generic output
|
||||||
|
- A manual quality-assurance step requiring three finance staff to review approximately 600 documents per day
|
||||||
|
- An inability to generate documents programmatically via API, blocking integrations with downstream ERP systems
|
||||||
|
|
||||||
|
The backlog had grown to 12,000 unprocessed documents at peak, and customer satisfaction scores related to document delivery had fallen to 61%.</div>
|
||||||
|
<div class="section-heading">Solution</div>
|
||||||
|
<div class="text-section">Nexus Cloud partnered with Stirling PDF to deploy a fully integrated document generation and processing pipeline. The solution centred on Stirling PDF's AI document creation capabilities, enabling Nexus Cloud to:
|
||||||
|
|
||||||
|
- Define and version-control a library of 24 branded document templates covering invoices, statements, remittance advice, and credit notes
|
||||||
|
- Generate PDF documents programmatically via a REST API with average end-to-end latency of 2.1 seconds
|
||||||
|
- Apply client-specific theming — including custom fonts, colour schemes, and logos — at the point of generation without manual intervention
|
||||||
|
- Route completed documents directly to Nexus Cloud's distribution service via webhook, eliminating the manual QA step for standard document types</div>
|
||||||
|
<div class="section-heading">Implementation</div>
|
||||||
|
<div class="text-section">The implementation was completed in three phases over eight weeks:
|
||||||
|
|
||||||
|
Phase 1 (Weeks 1–3): Template Migration. Nexus Cloud's design team worked with Stirling PDF's onboarding engineers to convert the 24 legacy document designs into Stirling PDF's HTML-based template format. All templates were validated against a sample of 500 historical documents.
|
||||||
|
|
||||||
|
Phase 2 (Weeks 4–6): API Integration. Nexus Cloud's engineering team integrated the Stirling PDF generation API into their existing invoice processing microservice. A staging environment with synthetic load testing at 200 concurrent requests confirmed that latency remained below 2.5 seconds at the 95th percentile.
|
||||||
|
|
||||||
|
Phase 3 (Weeks 7–8): Parallel Running and Cutover. Both systems ran in parallel for two weeks. A total of 38,000 documents were validated side-by-side. No material discrepancies were identified. The legacy system was decommissioned on 14 October 2025.</div>
|
||||||
|
<div class="section-heading">Results</div>
|
||||||
|
<div class="text-section">Within 90 days of full deployment, Nexus Cloud reported the following outcomes:
|
||||||
|
|
||||||
|
- Document generation time reduced from 8.2 seconds to 2.1 seconds — a 74% improvement
|
||||||
|
- Manual QA headcount reallocated from document review to higher-value tasks; estimated annual saving of £87,000 in operational cost
|
||||||
|
- Zero SLA breaches in November and December 2025 (versus 14 in the same period the prior year)
|
||||||
|
- Customer satisfaction score for document delivery increased from 61% to 89%
|
||||||
|
- 12 enterprise clients activated custom-branded templates within the first 60 days, generating an estimated £34,000 in incremental upsell revenue for Nexus Cloud
|
||||||
|
- The document backlog was eliminated within the first week of operation</div>
|
||||||
|
<div class="section-heading">Client Testimonial</div>
|
||||||
|
<div class="testimonial-block">
|
||||||
|
<div class="testimonial-quote">"Switching to Stirling PDF was one of the smoothest technology transitions we have made. The onboarding team understood our constraints from day one, and the API integration took our engineers less than a week. The impact on our document throughput and customer feedback has been immediate and measurable. We are already planning to extend the platform to cover our client-facing reporting suite in Q2 2026."</div>
|
||||||
|
<div class="testimonial-attribution">— Marcus Webb, VP of Engineering, Nexus Cloud Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Conclusion</div>
|
||||||
|
<div class="text-section">Nexus Cloud's experience demonstrates how organisations experiencing rapid growth can use Stirling PDF to eliminate document processing bottlenecks without sacrificing quality or brand consistency. By replacing a fragile legacy stack with a scalable, API-first generation platform, Nexus Cloud recovered significant operational capacity, improved customer satisfaction, and created new commercial opportunities through branded document customisation. The project delivered full return on investment within eleven weeks of go-live.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Committee Agenda</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attendees table ── */
|
||||||
|
.attendees-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendees-table thead tr {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendees-table thead th {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-border, #e2e8f0);
|
||||||
|
font-size: 8pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendees-table tbody td {
|
||||||
|
padding: 3pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agenda items table ── */
|
||||||
|
.agenda-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-table .item-num {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.agenda-table tbody tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Agenda</div>
|
||||||
|
<div class="doc-subtitle">{{COMMITTEE_NAME}} · {{ORG_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{MEETING_DATE}}</span>
|
||||||
|
<span class="meta-label">Time:</span> <span class="meta-value">{{MEETING_TIME}}</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">{{MEETING_LOCATION}}</span>
|
||||||
|
<span class="meta-label">Chair:</span> <span class="meta-value">{{CHAIR_NAME}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Expected Attendees -->
|
||||||
|
<div class="section-heading">Expected Attendees</div>
|
||||||
|
<table class="attendees-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Name</th>
|
||||||
|
<th style="width:35%">Title / Role</th>
|
||||||
|
<th style="width:25%">Organisation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ATTENDEES_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Agenda Items -->
|
||||||
|
<div class="section-heading">Agenda Items</div>
|
||||||
|
<table class="agenda-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:8%">#</th>
|
||||||
|
<th style="width:47%">Topic</th>
|
||||||
|
<th style="width:25%">Presenter</th>
|
||||||
|
<th style="width:20%">Time Allotted</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{AGENDA_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Documents / Papers Circulated -->
|
||||||
|
<div class="section-heading">Documents and Papers Circulated</div>
|
||||||
|
<div class="text-section">{{DOCUMENTS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Any Other Business -->
|
||||||
|
<div class="section-heading">Any Other Business (AOB)</div>
|
||||||
|
<div class="text-section">{{AOB_TEXT}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Committee Agenda</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.attendees-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.attendees-table thead tr { background: var(--theme-surface); }
|
||||||
|
.attendees-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-border); font-size: 8pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.attendees-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); }
|
||||||
|
.agenda-table { width: 100%; border-collapse: collapse; margin-bottom: 8pt; font-size: 8.5pt; }
|
||||||
|
.agenda-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.agenda-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.agenda-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.agenda-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.agenda-table .item-num { text-align: center; font-weight: 700; color: var(--theme-primary); white-space: nowrap; }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Committee Agenda</div>
|
||||||
|
<div class="doc-subtitle">Meridian Healthcare NHS Foundation Trust</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">26 March 2026</span>
|
||||||
|
<span class="meta-label">Time:</span> <span class="meta-value">14:00 – 16:30</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">Committee Room 3, Trust Headquarters, Bristol BS1 5TT</span>
|
||||||
|
<span class="meta-label">Chair:</span> <span class="meta-value">Sir Geoffrey Harwood, Non-Executive Director</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Expected Attendees</div>
|
||||||
|
<table class="attendees-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Name</th>
|
||||||
|
<th style="width:35%">Title / Role</th>
|
||||||
|
<th style="width:25%">Organisation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Sir Geoffrey Harwood</td><td>Non-Executive Director (Chair)</td><td>Meridian Healthcare NHS FT</td></tr>
|
||||||
|
<tr><td>Dr Anita Nwachukwu</td><td>Non-Executive Director</td><td>Meridian Healthcare NHS FT</td></tr>
|
||||||
|
<tr><td>Peter Sloane</td><td>Director of Finance</td><td>Meridian Healthcare NHS FT</td></tr>
|
||||||
|
<tr><td>Carolyn Bridges</td><td>Head of Internal Audit</td><td>Meridian Healthcare NHS FT</td></tr>
|
||||||
|
<tr><td>James Firth</td><td>Deputy Director of Governance</td><td>Meridian Healthcare NHS FT</td></tr>
|
||||||
|
<tr><td>Sarah Quilter</td><td>Engagement Lead</td><td>Grant Thornton UK LLP (External Auditors)</td></tr>
|
||||||
|
<tr><td>Helen Mcallister</td><td>Committee Secretary</td><td>Meridian Healthcare NHS FT</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Agenda Items</div>
|
||||||
|
<table class="agenda-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:8%">#</th>
|
||||||
|
<th style="width:47%">Topic</th>
|
||||||
|
<th style="width:25%">Presenter</th>
|
||||||
|
<th style="width:20%">Time Allotted</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="item-num">1</td><td>Welcome, Apologies and Declarations of Interest</td><td>Chair</td><td>5 mins</td></tr>
|
||||||
|
<tr><td class="item-num">2</td><td>Minutes of Previous Meeting (29 January 2026) and Matters Arising</td><td>Chair / Secretary</td><td>10 mins</td></tr>
|
||||||
|
<tr><td class="item-num">3</td><td>External Audit: Progress Report and Planned Approach for 2025/26 Accounts</td><td>S. Quilter</td><td>20 mins</td></tr>
|
||||||
|
<tr><td class="item-num">4</td><td>Internal Audit: Q3 Progress Report and Tracker of Open Recommendations</td><td>C. Bridges</td><td>25 mins</td></tr>
|
||||||
|
<tr><td class="item-num">5</td><td>Counter-Fraud Annual Update and Referral Summary</td><td>C. Bridges</td><td>15 mins</td></tr>
|
||||||
|
<tr><td class="item-num">6</td><td>Finance Report: Month 11 Management Accounts</td><td>P. Sloane</td><td>20 mins</td></tr>
|
||||||
|
<tr><td class="item-num">7</td><td>Board Assurance Framework: Risk Register Review</td><td>J. Firth</td><td>20 mins</td></tr>
|
||||||
|
<tr><td class="item-num">8</td><td>Committee Effectiveness Self-Assessment – 2025/26 Results</td><td>Chair</td><td>15 mins</td></tr>
|
||||||
|
<tr><td class="item-num">9</td><td>Any Other Business</td><td>Chair</td><td>5 mins</td></tr>
|
||||||
|
<tr><td class="item-num">10</td><td>Date of Next Meeting</td><td>Secretary</td><td>5 mins</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Documents and Papers Circulated</div>
|
||||||
|
<div class="text-section">The following papers have been circulated in advance of the meeting and should be read prior to attendance:
|
||||||
|
|
||||||
|
1. Draft minutes of the Audit and Risk Committee meeting held 29 January 2026
|
||||||
|
2. External Audit Progress Report – Grant Thornton UK LLP (March 2026)
|
||||||
|
3. Internal Audit Q3 Progress Report and Recommendations Tracker
|
||||||
|
4. Counter-Fraud Annual Summary Report 2025/26
|
||||||
|
5. Month 11 Management Accounts (February 2026)
|
||||||
|
6. Board Assurance Framework – Quarterly Risk Register Update
|
||||||
|
7. Committee Effectiveness Self-Assessment Summary Report
|
||||||
|
|
||||||
|
Papers are accessible via the Board portal. Hard copies available from the Committee Secretary on request.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Any Other Business (AOB)</div>
|
||||||
|
<div class="text-section">Members wishing to raise items under AOB should notify the Committee Secretary ([email protected]) no later than 24 hours before the meeting. The next scheduled meeting of the Audit and Risk Committee is Thursday, 28 May 2026 at 14:00.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Employee Handbook</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading (numbered, primary colour) ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 12pt 0 5pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sub-section heading ── */
|
||||||
|
.subsection-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin: 8pt 0 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body text ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.subsection-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{HANDBOOK_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Handbook meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Version:</span> <span class="meta-value">{{VERSION}}</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- 1. Welcome -->
|
||||||
|
<div class="section-heading">1.0 Welcome</div>
|
||||||
|
<div class="text-section">{{WELCOME_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- 2. Employment Policies -->
|
||||||
|
<div class="section-heading">2.0 Employment Policies</div>
|
||||||
|
<div class="text-section">{{EMPLOYMENT_POLICIES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- 3. Code of Conduct -->
|
||||||
|
<div class="section-heading">3.0 Code of Conduct</div>
|
||||||
|
<div class="text-section">{{CODE_OF_CONDUCT_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- 4. Benefits Overview -->
|
||||||
|
<div class="section-heading">4.0 Benefits Overview</div>
|
||||||
|
<div class="text-section">{{BENEFITS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- 5. Leave Policies -->
|
||||||
|
<div class="section-heading">5.0 Leave Policies</div>
|
||||||
|
<div class="text-section">{{LEAVE_POLICIES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- 6. Disciplinary Procedure -->
|
||||||
|
<div class="section-heading">6.0 Disciplinary Procedure</div>
|
||||||
|
<div class="text-section">{{DISCIPLINARY_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- 7. Acknowledgement -->
|
||||||
|
<div class="section-heading">7.0 Acknowledgement</div>
|
||||||
|
<div class="text-section">I, the undersigned, acknowledge that I have received, read, and understood the contents of this Employee Handbook. I agree to comply with all policies and procedures set out herein.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{EMPLOYEE_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{EMPLOYEE_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">HR Representative</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{HR_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{HR_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Employee Handbook</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 3pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 9pt; }
|
||||||
|
.meta-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 12pt 0 5pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 9pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 6pt; white-space: pre-line; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Employee Handbook</div>
|
||||||
|
<div class="doc-subtitle">Northgate Engineering Services Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Version:</span> <span class="meta-value">3.2</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">1 January 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">1.0 Welcome</div>
|
||||||
|
<div class="text-section">Welcome to Northgate Engineering Services Ltd. We are delighted that you have joined our team and hope that your time with us will be both rewarding and fulfilling.
|
||||||
|
|
||||||
|
This handbook sets out the key policies, procedures, and standards that govern our working environment. It is intended to give you a clear picture of what you can expect from us and what we expect from you. Please read it carefully and keep it for reference throughout your employment.
|
||||||
|
|
||||||
|
This handbook does not form part of your contract of employment. The company reserves the right to amend any policy set out herein at any time, subject to appropriate consultation. The most current version will always be available on the HR intranet portal.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">2.0 Employment Policies</div>
|
||||||
|
<div class="text-section"><strong>2.1 Equal Opportunities</strong>
|
||||||
|
Northgate Engineering Services is committed to providing equal opportunities for all employees and job applicants. We do not discriminate on the grounds of age, disability, gender reassignment, marriage and civil partnership, pregnancy and maternity, race, religion or belief, sex, or sexual orientation.
|
||||||
|
|
||||||
|
<strong>2.2 Probationary Period</strong>
|
||||||
|
All new employees are subject to a six-month probationary period. During this time, performance and conduct will be monitored closely. The company may extend the probationary period or terminate employment if performance or conduct does not meet the required standard.
|
||||||
|
|
||||||
|
<strong>2.3 Working Hours</strong>
|
||||||
|
Core working hours are Monday to Friday, 08:30 to 17:00, with a 30-minute unpaid lunch break. Flexible working arrangements may be agreed in writing with your line manager and HR.
|
||||||
|
|
||||||
|
<strong>2.4 Pay and Salary Review</strong>
|
||||||
|
Salaries are paid monthly by BACS transfer on the last working day of each month. Annual salary reviews take place in April each year; any increase is discretionary and subject to individual performance and business conditions.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">3.0 Code of Conduct</div>
|
||||||
|
<div class="text-section">All employees are expected to conduct themselves with professionalism, honesty, and integrity at all times. The following behaviours are required:
|
||||||
|
|
||||||
|
- Treat all colleagues, clients, and business partners with courtesy and respect.
|
||||||
|
- Maintain confidentiality in relation to company information, client data, and personal data of colleagues.
|
||||||
|
- Avoid any actual or perceived conflicts of interest; disclose potential conflicts to your line manager promptly.
|
||||||
|
- Use company assets, including IT systems, vehicles, and equipment, only for legitimate business purposes.
|
||||||
|
- Comply with all applicable laws, regulations, and company policies.
|
||||||
|
- Do not make unauthorised commitments or enter into contracts on behalf of the company.
|
||||||
|
|
||||||
|
Breaches of the code of conduct may result in disciplinary action up to and including dismissal.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">4.0 Benefits Overview</div>
|
||||||
|
<div class="text-section">Northgate Engineering Services offers a competitive benefits package, which includes:
|
||||||
|
|
||||||
|
- <strong>Pension:</strong> Employer contribution of 6% into a qualifying workplace pension scheme; employee minimum contribution of 3%.
|
||||||
|
- <strong>Private Medical Insurance:</strong> Individual cover provided through AXA Health from the date of joining; family cover available at a subsidised premium.
|
||||||
|
- <strong>Life Assurance:</strong> Death-in-service benefit of four times annual base salary.
|
||||||
|
- <strong>Annual Leave:</strong> 25 days per year, rising to 28 days after five years' continuous service, plus UK public holidays.
|
||||||
|
- <strong>Cycle to Work:</strong> Interest-free salary sacrifice scheme for bicycles and cycling equipment.
|
||||||
|
- <strong>Employee Assistance Programme:</strong> Confidential 24/7 support line covering mental health, financial, and legal advice.
|
||||||
|
- <strong>Professional Development:</strong> Support for relevant professional qualifications and membership fees, subject to line manager approval.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">5.0 Leave Policies</div>
|
||||||
|
<div class="text-section"><strong>5.1 Annual Leave</strong>
|
||||||
|
Annual leave must be agreed in advance with your line manager and booked via the HR system. A maximum of five days may be carried over into the next leave year; carried-over days expire on 31 March.
|
||||||
|
|
||||||
|
<strong>5.2 Sickness Absence</strong>
|
||||||
|
If you are unable to attend work due to illness, you must notify your line manager by telephone before your normal start time on the first day of absence. A self-certification form must be completed for absences of up to seven calendar days. A fit note from a GP is required for any absence exceeding seven calendar days.
|
||||||
|
|
||||||
|
<strong>5.3 Maternity, Paternity, and Shared Parental Leave</strong>
|
||||||
|
The company provides statutory maternity pay and leave entitlements in accordance with current UK legislation. Enhanced maternity pay of 90% of average weekly earnings is provided for the first 16 weeks for employees with at least one year's continuous service. Please contact HR for full details.
|
||||||
|
|
||||||
|
<strong>5.4 Other Leave</strong>
|
||||||
|
Compassionate leave of up to five days with full pay may be granted on the death of an immediate family member. Jury service leave will be granted; the company will make up the difference between jury service allowance and normal pay for a maximum of 10 working days.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">6.0 Disciplinary Procedure</div>
|
||||||
|
<div class="text-section">The company's disciplinary procedure is designed to ensure that all employees are treated fairly and consistently when concerns arise about conduct or performance. The procedure follows these stages:
|
||||||
|
|
||||||
|
<strong>Stage 1 — Informal Discussion:</strong> For minor issues, the line manager will hold an informal discussion to address the concern and agree on corrective action.
|
||||||
|
|
||||||
|
<strong>Stage 2 — Formal Investigation:</strong> Where informal resolution is not possible, or where the matter is sufficiently serious, a formal investigation will be conducted. The employee will be notified in writing and invited to a disciplinary hearing with appropriate notice.
|
||||||
|
|
||||||
|
<strong>Stage 3 — Disciplinary Hearing:</strong> The employee has the right to be accompanied by a trade union representative or work colleague. Following the hearing, the outcome may be: no further action, a written warning, a final written warning, or dismissal.
|
||||||
|
|
||||||
|
<strong>Stage 4 — Appeal:</strong> The employee may appeal against any disciplinary outcome within five working days of receiving the written decision.
|
||||||
|
|
||||||
|
Gross misconduct, including theft, fraud, violence, and serious breaches of data protection, may result in summary dismissal without notice.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">7.0 Acknowledgement</div>
|
||||||
|
<div class="text-section">I, the undersigned, acknowledge that I have received, read, and understood the contents of this Employee Handbook. I agree to comply with all policies and procedures set out herein.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Thomas Brennan</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 6 January 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">HR Representative</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Claire Whitfield</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 6 January 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Executive Summary</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin: 8pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{DOCUMENT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<div class="info-label">Prepared By</div>
|
||||||
|
<div class="info-value">{{PREPARED_BY}}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="info-label">Date</div>
|
||||||
|
<div class="info-value">{{DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<div class="section-heading">Overview</div>
|
||||||
|
<div class="text-section">{{OVERVIEW_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Key Findings -->
|
||||||
|
<div class="section-heading">Key Findings</div>
|
||||||
|
<div class="text-section">{{FINDINGS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Recommendations -->
|
||||||
|
<div class="section-heading">Recommendations</div>
|
||||||
|
<div class="text-section">{{RECOMMENDATIONS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Financial Impact -->
|
||||||
|
<div class="section-heading">Financial Impact</div>
|
||||||
|
<div class="text-section">{{FINANCIAL_IMPACT_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Next Steps -->
|
||||||
|
<div class="section-heading">Next Steps</div>
|
||||||
|
<div class="text-section">{{NEXT_STEPS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Conclusion -->
|
||||||
|
<div class="section-heading">Conclusion</div>
|
||||||
|
<div class="text-section">{{CONCLUSION_TEXT}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Executive Summary</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin: 8pt 0; }
|
||||||
|
.info-label { font-weight: 600; font-size: 8.5pt; color: var(--theme-text-muted); }
|
||||||
|
.info-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Executive Summary</div>
|
||||||
|
<div class="doc-subtitle">Aldgate Capital Partners LLP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div style="font-size: 11pt; font-weight: 600; color: var(--theme-heading); margin: 8pt 0 10pt;">UK Retail Sector Expansion Opportunity</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<div class="info-label">Prepared By</div>
|
||||||
|
<div class="info-value">Sophia Hartley, Director of Strategy</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="info-label">Date</div>
|
||||||
|
<div class="info-value">19 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Overview</div>
|
||||||
|
<div class="text-section">This executive summary presents the findings of a three-month strategic review into potential expansion of Aldgate Capital Partners' retail sector portfolio across the Midlands and North of England. The review was commissioned by the Investment Committee in November 2025 and draws on market data, competitor analysis, and direct stakeholder interviews with eight target acquisition companies.
|
||||||
|
|
||||||
|
The review assessed 14 candidate businesses with combined annual revenues of £320 million and identified three high-priority targets that meet the firm's return threshold of 22% IRR over a five-year horizon.</div>
|
||||||
|
<div class="section-heading">Key Findings</div>
|
||||||
|
<div class="text-section">1. The UK independent retail sector has shown resilient 6.2% revenue growth in 2025 despite macroeconomic headwinds, driven by consumer preference for experiential shopping and local provenance brands.
|
||||||
|
|
||||||
|
2. Three acquisition targets — Fenwick North, BrightGrocer Holdings, and Pennine Outdoor — demonstrate strong EBITDA margins of 14–19%, proven management teams, and clear scalability through supply-chain optimisation.
|
||||||
|
|
||||||
|
3. Consolidation activity in the sector has accelerated: seven transactions were completed in the Midlands alone in H2 2025, suggesting a narrowing window for acquisitions at current valuations.
|
||||||
|
|
||||||
|
4. Regulatory risk is low; no material competition or planning obstacles have been identified for the preferred targets.
|
||||||
|
|
||||||
|
5. Digital transformation readiness varies: Fenwick North is category-leading; BrightGrocer and Pennine Outdoor require investment of approximately £2.8 million combined to reach baseline e-commerce capability.</div>
|
||||||
|
<div class="section-heading">Recommendations</div>
|
||||||
|
<div class="text-section">1. Initiate formal due diligence on Fenwick North (primary target) immediately, with a target indicative offer by 15 April 2026. Estimated enterprise value: £48–54 million.
|
||||||
|
|
||||||
|
2. Commission a separate operational review of BrightGrocer Holdings' supply chain to validate management's 23% cost-reduction claim before advancing to Heads of Terms.
|
||||||
|
|
||||||
|
3. Place Pennine Outdoor on a six-month watchlist; monitor Q1 2026 trading results before committing further resource. A deterioration in EBITDA margin below 12% should trigger a withdrawal from consideration.
|
||||||
|
|
||||||
|
4. Engage the firm's preferred legal counsel (Clifford Chance LLP) and financial advisers (Rothschild & Co) by end of February 2026 to prepare transaction infrastructure.</div>
|
||||||
|
<div class="section-heading">Financial Impact</div>
|
||||||
|
<div class="text-section">Acquiring Fenwick North at the midpoint valuation of £51 million (using a 60:40 debt/equity structure) is projected to generate:
|
||||||
|
- IRR: 24.3% over five years (base case)
|
||||||
|
- Cash-on-cash multiple: 2.8x
|
||||||
|
- Projected EBITDA contribution to portfolio in Year 3: £9.2 million
|
||||||
|
|
||||||
|
Sensitivity analysis indicates that even under a downside scenario assuming 10% revenue contraction and 200bps margin compression, the IRR remains above the 18% hurdle rate. The full financial model is available in Appendix B.</div>
|
||||||
|
<div class="section-heading">Next Steps</div>
|
||||||
|
<div class="text-section">1. Investment Committee approval of this recommendation by 28 February 2026.
|
||||||
|
2. Execution of an NDA with Fenwick North management — week of 2 March 2026.
|
||||||
|
3. Kick-off of management presentations and site visits — week of 9 March 2026.
|
||||||
|
4. Indicative offer submission — by 15 April 2026.
|
||||||
|
5. Parallel engagement with BrightGrocer operational review team — March 2026 start.</div>
|
||||||
|
<div class="section-heading">Conclusion</div>
|
||||||
|
<div class="text-section">The strategic review confirms that a disciplined, targeted entry into the UK Midlands and Northern retail sector presents a compelling risk-adjusted opportunity for Aldgate Capital Partners. Fenwick North represents an immediately actionable priority with strong fundamentals and a management team that has expressed openness to a structured transaction. Swift action is recommended to pre-empt competing interest from two identified trade buyers. The Investment Committee's approval to proceed is sought at the next scheduled meeting on 26 February 2026.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Expense Report</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Employee info ── */
|
||||||
|
.employee-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin: 8pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 4pt 5pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 3pt 5pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Totals ── */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 9pt;
|
||||||
|
min-width: 140pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-top: 4pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reimbursement note ── */
|
||||||
|
.reimbursement-note {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.employee-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Expense Report</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Employee Info -->
|
||||||
|
<div class="employee-grid">
|
||||||
|
<div>
|
||||||
|
<div class="info-label">Employee Name</div>
|
||||||
|
<div class="info-value">{{EMPLOYEE_NAME}}</div>
|
||||||
|
<div class="info-label" style="margin-top:4pt">Department</div>
|
||||||
|
<div class="info-value">{{EMPLOYEE_DEPARTMENT}}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="info-label">Submission Date</div>
|
||||||
|
<div class="info-value">{{SUBMISSION_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expense Items -->
|
||||||
|
<div class="section-heading">Expense Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:18pt">#</th>
|
||||||
|
<th style="width:44pt">Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="width:44pt">Category</th>
|
||||||
|
<th style="width:44pt">Merchant</th>
|
||||||
|
<th style="width:28pt">Receipt</th>
|
||||||
|
<th class="num" style="width:22pt">Qty</th>
|
||||||
|
<th class="num" style="width:40pt">Unit Cost</th>
|
||||||
|
<th class="num" style="width:40pt">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{EXPENSE_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Mileage -->
|
||||||
|
<div class="section-heading">Mileage</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:18pt">#</th>
|
||||||
|
<th style="width:44pt">Date</th>
|
||||||
|
<th>Purpose</th>
|
||||||
|
<th style="width:50pt">From</th>
|
||||||
|
<th style="width:50pt">To</th>
|
||||||
|
<th class="num" style="width:40pt">Miles</th>
|
||||||
|
<th class="num" style="width:40pt">Rate</th>
|
||||||
|
<th class="num" style="width:40pt">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{MILEAGE_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Summary by Category -->
|
||||||
|
<div class="section-heading">Summary by Category</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th class="num" style="width:60pt">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{SUMMARY_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="total-row"><td>Total Amount:</td><td>{{TOTAL_AMOUNT_TEXT}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reimbursement -->
|
||||||
|
<div class="section-heading">Reimbursement Details</div>
|
||||||
|
<div class="reimbursement-note">{{REIMBURSEMENT_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Sign-Off</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{EMPLOYEE_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{EMPLOYEE_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Manager Approval</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{MANAGER_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{MANAGER_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Expense Report</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.employee-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin: 8pt 0; }
|
||||||
|
.info-label { font-weight: 600; font-size: 8.5pt; color: var(--theme-text-muted); }
|
||||||
|
.info-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 7.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 4pt 5pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 3pt 5pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 8pt; }
|
||||||
|
.totals-table { border-collapse: collapse; font-size: 9pt; min-width: 140pt; }
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||||
|
.totals-table td:last-child { text-align: right; }
|
||||||
|
.totals-table .total-row td { font-weight: 700; font-size: 10pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; color: var(--theme-heading); }
|
||||||
|
.reimbursement-note { font-size: 8.5pt; line-height: 1.5; margin-bottom: 4pt; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Expense Report</div>
|
||||||
|
<div class="doc-subtitle">Thornfield Media Group plc</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="employee-grid">
|
||||||
|
<div>
|
||||||
|
<div class="info-label">Employee Name</div>
|
||||||
|
<div class="info-value">Daniel Fitzgerald</div>
|
||||||
|
<div class="info-label" style="margin-top:4pt">Department</div>
|
||||||
|
<div class="info-value">Commercial Sales</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="info-label">Submission Date</div>
|
||||||
|
<div class="info-value">31 January 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Expense Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:18pt">#</th>
|
||||||
|
<th style="width:44pt">Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="width:44pt">Category</th>
|
||||||
|
<th style="width:44pt">Merchant</th>
|
||||||
|
<th style="width:28pt">Receipt</th>
|
||||||
|
<th class="num" style="width:22pt">Qty</th>
|
||||||
|
<th class="num" style="width:40pt">Unit Cost</th>
|
||||||
|
<th class="num" style="width:40pt">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>14 Jan 2026</td>
|
||||||
|
<td>Return train — London Euston to Manchester Piccadilly</td>
|
||||||
|
<td>Travel</td>
|
||||||
|
<td>Avanti West Coast</td>
|
||||||
|
<td>R-001</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td class="num">£142.00</td>
|
||||||
|
<td class="num">£142.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>14 Jan 2026</td>
|
||||||
|
<td>Client lunch — 3 attendees (Prospect meeting)</td>
|
||||||
|
<td>Meals</td>
|
||||||
|
<td>The Ivy, Manchester</td>
|
||||||
|
<td>R-002</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td class="num">£87.50</td>
|
||||||
|
<td class="num">£87.50</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>15 Jan 2026</td>
|
||||||
|
<td>Hotel accommodation — 1 night</td>
|
||||||
|
<td>Accommodation</td>
|
||||||
|
<td>Radisson Blu</td>
|
||||||
|
<td>R-003</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td class="num">£129.00</td>
|
||||||
|
<td class="num">£129.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>4</td>
|
||||||
|
<td>22 Jan 2026</td>
|
||||||
|
<td>Taxi to London Heathrow Terminal 5</td>
|
||||||
|
<td>Travel</td>
|
||||||
|
<td>Addison Lee</td>
|
||||||
|
<td>R-004</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td class="num">£54.00</td>
|
||||||
|
<td class="num">£54.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>5</td>
|
||||||
|
<td>22 Jan 2026</td>
|
||||||
|
<td>Return flight — London Heathrow to Edinburgh</td>
|
||||||
|
<td>Travel</td>
|
||||||
|
<td>British Airways</td>
|
||||||
|
<td>R-005</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td class="num">£218.00</td>
|
||||||
|
<td class="num">£218.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading">Mileage</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:18pt">#</th>
|
||||||
|
<th style="width:44pt">Date</th>
|
||||||
|
<th>Purpose</th>
|
||||||
|
<th style="width:50pt">From</th>
|
||||||
|
<th style="width:50pt">To</th>
|
||||||
|
<th class="num" style="width:40pt">Miles</th>
|
||||||
|
<th class="num" style="width:40pt">Rate</th>
|
||||||
|
<th class="num" style="width:40pt">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>19 Jan 2026</td>
|
||||||
|
<td>Client site visit</td>
|
||||||
|
<td>Home office</td>
|
||||||
|
<td>Basingstoke</td>
|
||||||
|
<td class="num">48</td>
|
||||||
|
<td class="num">£0.45</td>
|
||||||
|
<td class="num">£21.60</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading">Summary by Category</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th class="num" style="width:60pt">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Accommodation</td><td class="num">£129.00</td></tr>
|
||||||
|
<tr><td>Meals</td><td class="num">£87.50</td></tr>
|
||||||
|
<tr><td>Mileage</td><td class="num">£21.60</td></tr>
|
||||||
|
<tr><td>Travel</td><td class="num">£414.00</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="total-row"><td>Total Amount:</td><td>£652.10</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Reimbursement Details</div>
|
||||||
|
<div class="reimbursement-note">Please reimburse to Daniel Fitzgerald's designated expenses account. Payment is expected within 10 working days of approval.</div>
|
||||||
|
<div class="section-heading">Sign-Off</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Daniel Fitzgerald</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 31 January 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Manager Approval</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Harriet Dawson</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 3 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Incident Report</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Incident summary grid ── */
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell {
|
||||||
|
padding: 4pt 7pt;
|
||||||
|
border-right: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-bottom: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell:nth-child(even) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 7.5pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
margin-bottom: 1pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Severity badge ── */
|
||||||
|
.severity-critical {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fca5a5;
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 1pt 6pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-high {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fed7aa;
|
||||||
|
color: #7c2d12;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 1pt 6pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-medium {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fef08a;
|
||||||
|
color: #713f12;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 1pt 6pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-low {
|
||||||
|
display: inline-block;
|
||||||
|
background: #bbf7d0;
|
||||||
|
color: #14532d;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 1pt 6pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sign-off ── */
|
||||||
|
.signoff-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.signoff-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Incident Report</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}} · Report No: {{REPORT_NUMBER}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Incident Summary Grid -->
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Incident Date</div>
|
||||||
|
<div class="summary-value">{{INCIDENT_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Incident Time</div>
|
||||||
|
<div class="summary-value">{{INCIDENT_TIME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Location</div>
|
||||||
|
<div class="summary-value">{{INCIDENT_LOCATION}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Incident Type</div>
|
||||||
|
<div class="summary-value">{{INCIDENT_TYPE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Severity Level</div>
|
||||||
|
<div class="summary-value">{{SEVERITY_LEVEL}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Reporter</div>
|
||||||
|
<div class="summary-value">{{REPORTER_NAME}}, {{REPORTER_TITLE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- People Involved -->
|
||||||
|
<div class="section-heading">People Involved</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reported By:</span> <span class="meta-value">{{REPORTER_NAME}}, {{REPORTER_TITLE}}</span>
|
||||||
|
<span class="meta-label">Witnesses:</span> <span class="meta-value">{{WITNESSES_TEXT}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incident Description -->
|
||||||
|
<div class="section-heading">Incident Description</div>
|
||||||
|
<div class="text-section">{{DESCRIPTION_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Immediate Actions Taken -->
|
||||||
|
<div class="section-heading">Immediate Actions Taken</div>
|
||||||
|
<div class="text-section">{{IMMEDIATE_ACTIONS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Root Cause Analysis -->
|
||||||
|
<div class="section-heading">Root Cause Analysis</div>
|
||||||
|
<div class="text-section">{{ROOT_CAUSE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Corrective Actions -->
|
||||||
|
<div class="section-heading">Corrective Actions</div>
|
||||||
|
<div class="text-section">{{CORRECTIVE_ACTIONS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div class="section-heading">Attachments</div>
|
||||||
|
<div class="text-section">{{ATTACHMENTS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Sign-off -->
|
||||||
|
<div class="section-heading">Sign-Off and Review</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Review Date:</span> <span class="meta-value">{{REVIEW_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Reviewer / Authoriser</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{REVIEWER_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Reporter</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{REPORTER_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Incident Report</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 0.5pt solid var(--theme-border); margin-bottom: 8pt; font-size: 8.5pt; }
|
||||||
|
.summary-cell { padding: 4pt 7pt; border-right: 0.5pt solid var(--theme-border); border-bottom: 0.5pt solid var(--theme-border); }
|
||||||
|
.summary-cell:nth-child(even) { border-right: none; }
|
||||||
|
.summary-label { font-weight: 600; color: var(--theme-text-muted); font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 1pt; }
|
||||||
|
.summary-value { color: var(--theme-text); font-size: 8.5pt; }
|
||||||
|
.severity-high { display: inline-block; background: #fed7aa; color: #7c2d12; font-weight: 700; font-size: 8pt; padding: 1pt 6pt; border-radius: 2pt; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.6; margin-bottom: 4pt; }
|
||||||
|
.signoff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 14pt; }
|
||||||
|
.signoff-col { font-size: 8.5pt; }
|
||||||
|
.signoff-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 18pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Incident Report</div>
|
||||||
|
<div class="doc-subtitle">Ironbridge Engineering Solutions Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Report No</div>
|
||||||
|
<div class="doc-number">IR-2026-041</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Incident Date</div>
|
||||||
|
<div class="summary-value">4 February 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Incident Time</div>
|
||||||
|
<div class="summary-value">08:47</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Location</div>
|
||||||
|
<div class="summary-value">Workshop Bay 3, Telford Manufacturing Site, TF1 5RQ</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Incident Type</div>
|
||||||
|
<div class="summary-value">Workplace Injury – Manual Handling</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Severity Level</div>
|
||||||
|
<div class="summary-value"><span class="severity-high">High</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-cell">
|
||||||
|
<div class="summary-label">Reporter</div>
|
||||||
|
<div class="summary-value">Gary Lockwood, Shift Supervisor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">People Involved</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reported By:</span> <span class="meta-value">Gary Lockwood, Shift Supervisor</span>
|
||||||
|
<span class="meta-label">Witnesses:</span> <span class="meta-value">Trevor Baines (Fabrication Technician); Sandra Okafor (Health and Safety Officer)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Incident Description</div>
|
||||||
|
<div class="text-section">At approximately 08:47 on 4 February 2026, Ian Morecroft (Fabrication Operative, Grade 3) sustained an injury to his lower back while manually moving a steel fabrication jig weighing approximately 42 kg in Workshop Bay 3. Mr Morecroft had been attempting to reposition the jig from a floor-level pallet onto a workbench without mechanical lifting assistance. He did not use the overhead gantry hoist fitted in Bay 3, citing that it was in use by a colleague for a separate task.
|
||||||
|
|
||||||
|
Mr Morecroft reported immediate onset of acute lower back pain and was unable to continue work. He was escorted to the site first-aid room by Shift Supervisor Gary Lockwood at 08:52. The on-site first aider assessed the injury and called 999 at 09:10 due to worsening symptoms. Mr Morecroft was transported to Princess Royal Hospital, Telford, by ambulance at 09:25. He was diagnosed with a lumbar muscle strain and minor disc compression and was signed off work for a minimum of four weeks.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Immediate Actions Taken</div>
|
||||||
|
<div class="text-section">1. Mr Morecroft was removed from Workshop Bay 3 immediately and first aid was administered on-site.
|
||||||
|
2. Emergency services were called at 09:10 and Mr Morecroft was transported to hospital by ambulance.
|
||||||
|
3. Workshop Bay 3 was cordoned off pending investigation and the jig was secured in place.
|
||||||
|
4. The incident was verbally reported to the Site Health and Safety Manager (Sandra Okafor) at 09:00 and formally notified to the Health and Safety Executive (HSE) under RIDDOR 2013 on 4 February 2026 (Reference: HSE-RIDDOR-2026-11874).
|
||||||
|
5. All manual handling activities in Workshop Bay 3 were suspended pending a risk assessment review.
|
||||||
|
6. Mr Morecroft's next of kin were notified at 09:30.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Root Cause Analysis</div>
|
||||||
|
<div class="text-section">Primary cause: Failure to follow the site Manual Handling Procedure (SHE-P-004, Rev 2) which requires mechanical lifting assistance for loads exceeding 25 kg. Mr Morecroft confirmed he was aware of the procedure but considered the move to be a quick, short-distance task.
|
||||||
|
|
||||||
|
Contributing factors:
|
||||||
|
- The sole overhead gantry hoist in Bay 3 was in use at the time of the incident, creating a perceived operational bottleneck that encouraged unsafe ad hoc lifting.
|
||||||
|
- A toolbox talk on manual handling conducted in November 2025 did not include a practical demonstration or competency check for operatives working with heavy fabrication components.
|
||||||
|
- The risk assessment for Bay 3 workbench loading tasks (RA-BAY3-001) had not been reviewed since March 2024 and did not reflect the addition of heavier jig stock introduced in September 2025.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Corrective Actions</div>
|
||||||
|
<div class="text-section">1. Install a second overhead gantry hoist in Workshop Bay 3 to eliminate single-point bottlenecks for heavy lifting tasks. Target completion: 28 February 2026. Owner: Operations Manager (D. Hartington).
|
||||||
|
2. Conduct a refresher manual handling training session for all Bay 3 operatives, including a practical competency assessment. Target completion: 14 February 2026. Owner: Health and Safety Officer (S. Okafor).
|
||||||
|
3. Review and update risk assessment RA-BAY3-001 to reflect current load weights and introduce a mandatory pre-lift checklist. Target completion: 11 February 2026. Owner: Health and Safety Officer (S. Okafor).
|
||||||
|
4. Brief all shift supervisors on enforcement of mechanical assistance requirements and the escalation procedure when equipment is unavailable. Target completion: 7 February 2026. Owner: Site Manager (P. Fairweather).
|
||||||
|
5. Review all site risk assessments for manual handling tasks to identify any others that have not been updated within the 12-month review cycle. Target completion: 31 March 2026. Owner: Health and Safety Officer (S. Okafor).</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Attachments</div>
|
||||||
|
<div class="text-section">1. First Aid Record – Bay 3 First Aid Room Log, 4 February 2026
|
||||||
|
2. RIDDOR Notification Confirmation – HSE Reference HSE-RIDDOR-2026-11874
|
||||||
|
3. Risk Assessment RA-BAY3-001 (current version, March 2024)
|
||||||
|
4. Manual Handling Procedure SHE-P-004 Rev 2
|
||||||
|
5. Workshop Bay 3 Site Photographs (8 images, taken 4 February 2026 09:45)
|
||||||
|
6. Witness Statement – Trevor Baines, dated 4 February 2026
|
||||||
|
7. Witness Statement – Sandra Okafor, dated 4 February 2026</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Sign-Off and Review</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Review Date:</span> <span class="meta-value">6 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Reviewer / Authoriser</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Paul Fairweather, Site Manager</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Reporter</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Gary Lockwood, Shift Supervisor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Independent Contractor Agreement</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sub-section ── */
|
||||||
|
.legal-subsection {
|
||||||
|
margin-top: 6pt;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
margin-left: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-subsection-heading {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-subsection-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{AGREEMENT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">Independent Contractor Agreement</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">{{AGREEMENT_REF}}</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">{{GOVERNING_LAW}}</span>
|
||||||
|
<span class="meta-label">Jurisdiction:</span> <span class="meta-value">{{JURISDICTION}}</span>
|
||||||
|
<span class="meta-label">Classification:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Company</div>
|
||||||
|
<div class="party-name">{{COMPANY_LEGAL_NAME}}</div>
|
||||||
|
<div class="party-detail">{{COMPANY_ENTITY_TYPE}}</div>
|
||||||
|
<div class="party-detail">Reg. No.: {{COMPANY_REG_NO}}</div>
|
||||||
|
<div class="party-detail">{{COMPANY_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Contractor</div>
|
||||||
|
<div class="party-name">{{CONTRACTOR_LEGAL_NAME}}</div>
|
||||||
|
<div class="party-detail">{{CONTRACTOR_ENTITY_TYPE}}</div>
|
||||||
|
<div class="party-detail">Reg. No.: {{CONTRACTOR_REG_NO}}</div>
|
||||||
|
<div class="party-detail">{{CONTRACTOR_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal sections -->
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Services</div>
|
||||||
|
<div class="legal-section-body">{{SERVICES_DESCRIPTION_TEXT}}</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.1 Deliverables</div>
|
||||||
|
<div class="legal-section-body">{{DELIVERABLES_ITEMS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.2 Term</div>
|
||||||
|
<div class="legal-section-body">{{TERM_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.3 Work Location</div>
|
||||||
|
<div class="legal-section-body">{{WORK_LOCATION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.4 Time Commitment</div>
|
||||||
|
<div class="legal-section-body">{{TIME_COMMITMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Fees and Expenses</div>
|
||||||
|
<div class="legal-section-body">{{FEES_TEXT}}</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.1 Expenses</div>
|
||||||
|
<div class="legal-section-body">{{EXPENSES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.2 Invoicing</div>
|
||||||
|
<div class="legal-section-body">{{INVOICING_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.3 Payment Terms</div>
|
||||||
|
<div class="legal-section-body">{{PAYMENT_TERMS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.4 Tax Status</div>
|
||||||
|
<div class="legal-section-body">{{TAX_STATUS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">{{IP_ASSIGNMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">{{CONFIDENTIALITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Data Protection</div>
|
||||||
|
<div class="legal-section-body">{{DATA_PROTECTION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Non-Solicitation</div>
|
||||||
|
<div class="legal-section-body">{{NON_SOLICIT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Non-Compete</div>
|
||||||
|
<div class="legal-section-body">{{NON_COMPETE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Independent Contractor Status</div>
|
||||||
|
<div class="legal-section-body">{{INDEPENDENT_STATUS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Warranties</div>
|
||||||
|
<div class="legal-section-body">{{WARRANTIES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">{{LIABILITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">11. Indemnity</div>
|
||||||
|
<div class="legal-section-body">{{INDEMNITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">12. Termination</div>
|
||||||
|
<div class="legal-section-body">{{TERMINATION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">13. Notices</div>
|
||||||
|
<div class="legal-section-body">{{NOTICES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">14. Assignment</div>
|
||||||
|
<div class="legal-section-body">{{ASSIGNMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">15. Entire Agreement</div>
|
||||||
|
<div class="legal-section-body">{{ENTIRE_AGREEMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules -->
|
||||||
|
<div class="section-heading">Schedules</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-body">{{SCHEDULES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have caused this Agreement to be executed by their duly authorised representatives.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Company</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: {{COMPANY_SIGNATORY}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Contractor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: {{CONTRACTOR_SIGNATORY}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Independent Contractor Agreement — Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { text-align: center; margin-bottom: 8pt; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 130pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.legal-subsection { margin-top: 6pt; margin-bottom: 6pt; margin-left: 10pt; }
|
||||||
|
.legal-subsection-heading { font-size: 9pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-title">Independent Contractor Agreement</div>
|
||||||
|
<div class="doc-subtitle">Independent Contractor Agreement</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">ICA-2026-004</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">1 March 2026</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">England and Wales</span>
|
||||||
|
<span class="meta-label">Jurisdiction:</span> <span class="meta-value">Courts of England and Wales</span>
|
||||||
|
<span class="meta-label">Classification:</span> <span class="meta-value">Confidential</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Company</div>
|
||||||
|
<div class="party-name">Vantage Digital Solutions Ltd</div>
|
||||||
|
<div class="party-detail">A private limited company</div>
|
||||||
|
<div class="party-detail">Reg. No.: 11223344</div>
|
||||||
|
<div class="party-detail">25 Broadgate, London, EC2M 2QS, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Contractor</div>
|
||||||
|
<div class="party-name">Owen Clarke Consulting Ltd</div>
|
||||||
|
<div class="party-detail">A private limited company</div>
|
||||||
|
<div class="party-detail">Reg. No.: 99887766</div>
|
||||||
|
<div class="party-detail">14 Canal Street, Manchester, M1 3HW, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Services</div>
|
||||||
|
<div class="legal-section-body">The Contractor will provide software engineering and technical architecture services to the Company on a project basis as described in this Agreement and any Statements of Work issued hereunder.</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.1 Deliverables</div>
|
||||||
|
<div class="legal-section-body">Design and implement backend API services for the Company's core product platform.
|
||||||
|
Participate in sprint planning, code reviews, and architecture discussions.
|
||||||
|
Provide written documentation for all major components delivered.
|
||||||
|
Attend weekly project standups (remote attendance acceptable).</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.2 Term</div>
|
||||||
|
<div class="legal-section-body">This Agreement commences on 1 March 2026 and continues for six (6) months, unless earlier terminated. The parties may extend by written agreement.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.3 Work Location</div>
|
||||||
|
<div class="legal-section-body">Services shall be performed primarily remotely. On-site attendance at the Company's London office may be requested with reasonable notice, not to exceed two days per month.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">1.4 Time Commitment</div>
|
||||||
|
<div class="legal-section-body">Approximately three (3) days per week, subject to variation by mutual agreement.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Fees and Expenses</div>
|
||||||
|
<div class="legal-section-body">The Company shall pay the Contractor a daily rate of £650 (exclusive of VAT) for each day of services rendered, as confirmed in approved timesheets.</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.1 Expenses</div>
|
||||||
|
<div class="legal-section-body">Reasonable pre-approved travel and out-of-pocket expenses will be reimbursed at cost on provision of receipts.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.2 Invoicing</div>
|
||||||
|
<div class="legal-section-body">The Contractor shall submit invoices on a monthly basis, itemising days worked and any approved expenses.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.3 Payment Terms</div>
|
||||||
|
<div class="legal-section-body">Payment shall be due within thirty (30) days of the date of a valid invoice.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-subsection">
|
||||||
|
<div class="legal-subsection-heading">2.4 Tax Status</div>
|
||||||
|
<div class="legal-section-body">The Contractor is responsible for all taxes, national insurance, and statutory obligations in respect of fees received under this Agreement. The Company shall have no obligation to deduct or withhold taxes unless required by law.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">All work product, code, designs, and deliverables created by the Contractor specifically for the Company in the performance of the Services ("Work Product") shall be assigned to and vest in the Company upon creation. The Contractor retains rights in any pre-existing materials and third-party tools incorporated into deliverables, subject to granting the Company a perpetual licence to use such materials for the purposes of the Work Product.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">The Contractor shall treat all non-public information of the Company as strictly confidential and shall not disclose it to third parties or use it for any purpose other than performing the Services. This obligation shall survive termination for three (3) years.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Data Protection</div>
|
||||||
|
<div class="legal-section-body">The Contractor shall comply with all applicable data protection legislation (including the UK GDPR and the Data Protection Act 2018) in processing any personal data in connection with this Agreement and shall enter into any data processing agreement reasonably required by the Company.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Non-Solicitation</div>
|
||||||
|
<div class="legal-section-body">During the term and for six (6) months after termination, the Contractor shall not directly solicit any employee or contractor of the Company with whom it had material contact, except through general non-targeted advertisements.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Non-Compete</div>
|
||||||
|
<div class="legal-section-body">During the term of this Agreement, the Contractor shall not provide substantially similar services to any direct competitor of the Company without the Company's prior written consent. This restriction shall not apply post-termination.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Independent Contractor Status</div>
|
||||||
|
<div class="legal-section-body">The Contractor is an independent contractor and not an employee, worker, agent, or partner of the Company. The Contractor has no authority to bind the Company and shall not represent itself as doing so.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Warranties</div>
|
||||||
|
<div class="legal-section-body">The Contractor warrants that it has the right to enter into this Agreement, that the Services will be performed with reasonable skill and care, and that the deliverables will not infringe the intellectual property rights of any third party.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">Neither party shall be liable for indirect or consequential loss. Each party's aggregate liability under this Agreement shall not exceed the total fees paid or payable in the three (3) months preceding the relevant claim, except in respect of death, personal injury, or fraud.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">11. Indemnity</div>
|
||||||
|
<div class="legal-section-body">Each party shall indemnify the other against third-party claims arising from its breach of this Agreement, gross negligence, or wilful misconduct, subject to the limitation of liability above.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">12. Termination</div>
|
||||||
|
<div class="legal-section-body">Either party may terminate this Agreement on thirty (30) days' written notice. Either party may terminate immediately on written notice if the other party commits a material breach that is not remedied within fourteen (14) days of notice, becomes insolvent, or enters administration.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">13. Notices</div>
|
||||||
|
<div class="legal-section-body">Notices shall be in writing and sent by email (with read receipt) or recorded post to the addresses specified in this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">14. Assignment</div>
|
||||||
|
<div class="legal-section-body">Neither party may assign or sub-contract its rights or obligations without the prior written consent of the other party, such consent not to be unreasonably withheld.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">15. Entire Agreement</div>
|
||||||
|
<div class="legal-section-body">This Agreement constitutes the entire agreement between the parties and supersedes all prior agreements, representations, and understandings relating to its subject matter.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Schedules</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-body">Schedule 1 — Statement of Work (SOW) Template
|
||||||
|
Schedule 2 — Approved Rates and Expense Policy</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have caused this Agreement to be executed by their duly authorised representatives.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Company</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: Sarah Whitfield, CEO</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 1 March 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Contractor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: Owen Clarke, Director</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 1 March 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Invoice {{INVOICE_NUMBER}}</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top accent bar ── */
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta info grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value.due-date {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Party cards (matching purchase_order style) ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card {
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-radius: 3pt;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card-header {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Line items table ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table .desc-col { width: 40%; }
|
||||||
|
|
||||||
|
/* ── Totals block ── */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
min-width: 150pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-top: 4pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .balance-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.party-card { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Invoice</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Invoice Number</div>
|
||||||
|
<div class="doc-number">{{INVOICE_NUMBER}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Invoice Date:</span> <span class="meta-value">{{INVOICE_DATE}}</span>
|
||||||
|
<span class="meta-label">Due Date:</span> <span class="meta-value due-date">{{DUE_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">From</div>
|
||||||
|
<div class="party-name">{{SUPPLIER_NAME}}</div>
|
||||||
|
<div class="party-detail">{{SUPPLIER_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Bill To</div>
|
||||||
|
<div class="party-name">{{CUSTOMER_NAME}}</div>
|
||||||
|
<div class="party-detail">{{CUSTOMER_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items -->
|
||||||
|
<div class="section-heading">Line Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22pt">#</th>
|
||||||
|
<th class="desc-col">Description</th>
|
||||||
|
<th class="num" style="width:32pt">Qty</th>
|
||||||
|
<th style="width:32pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{LINEITEM_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>{{SUBTOTAL_TEXT}}</td></tr>
|
||||||
|
<tr><td>Tax:</td><td>{{TAX_TEXT}}</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>{{TOTAL_TEXT}}</td></tr>
|
||||||
|
<tr class="balance-row"><td>Balance Due:</td><td>{{BALANCE_DUE_TEXT}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Terms -->
|
||||||
|
<div class="section-heading">Payment Terms</div>
|
||||||
|
<div class="text-section">{{PAYMENT_TERMS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Bank Details -->
|
||||||
|
<div class="section-heading">Bank Details</div>
|
||||||
|
<div class="text-section">{{BANK_DETAILS_TEXT}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Invoice</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.meta-value.due-date { font-weight: 600; color: var(--theme-accent); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-card { border: 0.5pt solid var(--theme-border); border-radius: 3pt; padding: 6pt 8pt; }
|
||||||
|
.party-card-header { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 4pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 10pt; }
|
||||||
|
.totals-table { border-collapse: collapse; font-size: 8.5pt; min-width: 150pt; }
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||||
|
.totals-table td:last-child { text-align: right; }
|
||||||
|
.totals-table .total-row td { font-weight: 700; font-size: 10pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; color: var(--theme-heading); }
|
||||||
|
.totals-table .balance-row td { font-weight: 700; font-size: 10pt; color: var(--theme-accent); }
|
||||||
|
.text-section { font-size: 8.5pt; line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Invoice</div>
|
||||||
|
<div class="doc-subtitle">Hartwell Consulting Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Invoice Number</div>
|
||||||
|
<div class="doc-number">INV-2026-0047</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Invoice Date:</span> <span class="meta-value">15 January 2026</span>
|
||||||
|
<span class="meta-label">Due Date:</span> <span class="meta-value due-date">14 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">From</div>
|
||||||
|
<div class="party-name">Hartwell Consulting Ltd</div>
|
||||||
|
<div class="party-detail">22 Canary Wharf, London, E14 5AB, UK</div>
|
||||||
|
<div class="party-detail">VAT No: GB123456789</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Bill To</div>
|
||||||
|
<div class="party-name">Pemberton Industries plc</div>
|
||||||
|
<div class="party-detail">8 Bridge Street, Manchester, M1 2JF, UK</div>
|
||||||
|
<div class="party-detail">Accounts Payable Dept.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Line Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22pt">#</th>
|
||||||
|
<th style="width:40%">Description</th>
|
||||||
|
<th class="num" style="width:32pt">Qty</th>
|
||||||
|
<th style="width:32pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>Strategic advisory — Q4 2025 retainer</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td>month</td>
|
||||||
|
<td class="num">£4,500.00</td>
|
||||||
|
<td class="num">£4,500.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Market analysis report — Sector Overview 2026</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td>report</td>
|
||||||
|
<td class="num">£1,200.00</td>
|
||||||
|
<td class="num">£1,200.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Workshop facilitation (half-day, 12 Jan 2026)</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td>session</td>
|
||||||
|
<td class="num">£950.00</td>
|
||||||
|
<td class="num">£950.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>£6,650.00</td></tr>
|
||||||
|
<tr><td>VAT (20%):</td><td>£1,330.00</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>£7,980.00</td></tr>
|
||||||
|
<tr class="balance-row"><td>Balance Due:</td><td>£7,980.00</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Payment Terms</div>
|
||||||
|
<div class="text-section">Payment is due within 30 days of the invoice date. Late payments may incur interest at 8% per annum above the Bank of England base rate under the Late Payment of Commercial Debts (Interest) Act 1998.</div>
|
||||||
|
<div class="section-heading">Bank Details</div>
|
||||||
|
<div class="text-section">Account Name: Hartwell Consulting Ltd
|
||||||
|
Sort Code: 20-00-00
|
||||||
|
Account Number: 12345678
|
||||||
|
Bank: Lloyds Bank plc, London
|
||||||
|
Reference: INV-2026-0047</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Job Description</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bullet lists (li items provided via placeholder) ── */
|
||||||
|
.bullet-list {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
padding-left: 14pt;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bullet-list li {
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.bullet-list li { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{JOB_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Job details -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Department:</span> <span class="meta-value">{{DEPARTMENT}}</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">{{LOCATION}}</span>
|
||||||
|
<span class="meta-label">Employment Type:</span> <span class="meta-value">{{EMPLOYMENT_TYPE}}</span>
|
||||||
|
<span class="meta-label">Salary Range:</span> <span class="meta-value">{{SALARY_RANGE}}</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- About the Role -->
|
||||||
|
<div class="section-heading">About the Role</div>
|
||||||
|
<div class="text-section">{{ABOUT_ROLE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Key Responsibilities -->
|
||||||
|
<div class="section-heading">Key Responsibilities</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
{{RESPONSIBILITIES_ITEMS}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Required Qualifications -->
|
||||||
|
<div class="section-heading">Required Qualifications</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
{{REQUIRED_QUALS_ITEMS}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Preferred Qualifications -->
|
||||||
|
<div class="section-heading">Preferred Qualifications</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
{{PREFERRED_QUALS_ITEMS}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- What We Offer -->
|
||||||
|
<div class="section-heading">What We Offer</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
{{BENEFITS_ITEMS}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- How to Apply -->
|
||||||
|
<div class="section-heading">How to Apply</div>
|
||||||
|
<div class="text-section">{{HOW_TO_APPLY_TEXT}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Job Description</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.job-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3pt 14pt; font-size: 8.5pt; margin-bottom: 8pt; }
|
||||||
|
.job-meta-item strong { font-weight: 600; color: var(--theme-text-muted); margin-right: 3pt; }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 9.5pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.bullet-list { font-size: 9.5pt; line-height: 1.6; color: var(--theme-text); padding-left: 14pt; margin-bottom: 4pt; }
|
||||||
|
.bullet-list li { margin-bottom: 3pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Job Description</div>
|
||||||
|
<div class="doc-subtitle">Hartwell Digital Agency Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div style="font-size: 14pt; font-weight: 700; color: var(--theme-heading); margin: 6pt 0 4pt;">Lead Software Engineer</div>
|
||||||
|
<div class="job-meta-grid">
|
||||||
|
<div class="job-meta-item"><strong>Department:</strong> Product Engineering</div>
|
||||||
|
<div class="job-meta-item"><strong>Location:</strong> Manchester (Hybrid — 2 days in office)</div>
|
||||||
|
<div class="job-meta-item"><strong>Employment Type:</strong> Full-Time, Permanent</div>
|
||||||
|
<div class="job-meta-item"><strong>Salary Range:</strong> £75,000 – £90,000 per annum</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">About the Role</div>
|
||||||
|
<div class="text-section">Hartwell Digital Agency is looking for an experienced and technically driven Lead Software Engineer to join our Product Engineering team in Manchester. In this role you will own the technical direction of one of our core SaaS product lines, leading a team of six engineers through the full software development lifecycle.
|
||||||
|
|
||||||
|
You will work closely with the Product Manager, Head of Engineering, and key stakeholders to translate strategic objectives into well-architected, scalable, and maintainable software. This is a hands-on leadership role; we expect you to write code, review pull requests, and set the technical standards for the team.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Key Responsibilities</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
<li>Lead the design and delivery of new product features, ensuring high code quality, performance, and security.</li>
|
||||||
|
<li>Manage, mentor, and develop a team of six software engineers, conducting regular one-to-ones and performance reviews.</li>
|
||||||
|
<li>Collaborate with the Product Manager to refine requirements, estimate effort, and prioritise the backlog.</li>
|
||||||
|
<li>Own the technical architecture of assigned product areas; produce and review design documentation.</li>
|
||||||
|
<li>Establish and enforce engineering best practices including code review standards, testing strategies, and deployment processes.</li>
|
||||||
|
<li>Contribute to cross-team initiatives such as platform reliability, developer tooling, and security improvements.</li>
|
||||||
|
<li>Participate in an on-call rota and act as the technical escalation point for production incidents.</li>
|
||||||
|
<li>Stay current with emerging technologies and proactively identify opportunities to improve the product.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-heading">Required Qualifications</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
<li>Minimum 6 years of professional software engineering experience, with at least 2 years in a technical lead or senior role.</li>
|
||||||
|
<li>Strong proficiency in TypeScript and Node.js; experience with React or similar modern front-end frameworks.</li>
|
||||||
|
<li>Solid understanding of RESTful API design, microservices architecture, and cloud-native development (AWS preferred).</li>
|
||||||
|
<li>Experience with relational and NoSQL databases (PostgreSQL and MongoDB or equivalent).</li>
|
||||||
|
<li>Demonstrable experience leading Agile / Scrum teams and facilitating sprint ceremonies.</li>
|
||||||
|
<li>Strong written and verbal communication skills; ability to present technical concepts to non-technical stakeholders.</li>
|
||||||
|
<li>A degree in Computer Science, Software Engineering, or a related discipline, or equivalent practical experience.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-heading">Preferred Qualifications</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
<li>Experience with Kubernetes and containerised deployments (Docker, Helm).</li>
|
||||||
|
<li>Familiarity with event-driven architectures (Kafka, RabbitMQ, or equivalent).</li>
|
||||||
|
<li>AWS certifications (Solutions Architect or Developer – Associate level or above).</li>
|
||||||
|
<li>Prior experience in a digital agency or SaaS product company.</li>
|
||||||
|
<li>Open-source contributions or personal projects demonstrating ongoing technical curiosity.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-heading">What We Offer</div>
|
||||||
|
<ul class="bullet-list">
|
||||||
|
<li>Competitive salary of £75,000 – £90,000, reviewed annually in April.</li>
|
||||||
|
<li>Annual performance bonus of up to 12% of base salary.</li>
|
||||||
|
<li>25 days' annual leave plus UK bank holidays, rising to 28 days after three years.</li>
|
||||||
|
<li>Employer pension contribution of 6% into a NEST workplace scheme.</li>
|
||||||
|
<li>Private health insurance (Vitality Health) from day one, with option to add family members.</li>
|
||||||
|
<li>£2,000 per year personal learning and development budget.</li>
|
||||||
|
<li>MacBook Pro and peripherals budget of £500 for home office set-up.</li>
|
||||||
|
<li>Cycle-to-work scheme and season ticket loan.</li>
|
||||||
|
<li>Flexible working hours with core hours of 10:00–16:00.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="section-heading">How to Apply</div>
|
||||||
|
<div class="text-section">Please submit your CV and a covering letter (maximum one page) explaining why you are interested in this role and what you would bring to the team. Applications should be sent to [email protected] with the subject line "Lead Software Engineer — [Your Name]".
|
||||||
|
|
||||||
|
The closing date for applications is 13 March 2026. First-stage interviews will take place remotely via video call during the week commencing 23 March 2026. Shortlisted candidates will be invited to a technical assessment and in-person panel interview at our Manchester office.
|
||||||
|
|
||||||
|
Hartwell Digital Agency is an equal opportunities employer. We welcome applications from candidates of all backgrounds and are committed to creating an inclusive workplace. We are happy to discuss reasonable adjustments throughout the recruitment process.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Business Letter</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Letterhead ── */
|
||||||
|
.letterhead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 8pt;
|
||||||
|
border-bottom: 1.5pt solid var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-block {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-company {
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-contact {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Date ── */
|
||||||
|
.letter-date {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Recipient ── */
|
||||||
|
.recipient-block {
|
||||||
|
margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-detail {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subject line ── */
|
||||||
|
.subject-line {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
margin-bottom: 12pt;
|
||||||
|
padding: 4pt 0;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-bottom: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-label {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
margin-right: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Salutation ── */
|
||||||
|
.salutation {
|
||||||
|
font-size: 10pt;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.body-text {
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Closing ── */
|
||||||
|
.closing {
|
||||||
|
font-size: 10pt;
|
||||||
|
margin-bottom: 30pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-block {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-name {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 1pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-title {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 1pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-company {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer extras ── */
|
||||||
|
.letter-footer {
|
||||||
|
margin-top: 16pt;
|
||||||
|
padding-top: 6pt;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-block { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<!-- Letterhead -->
|
||||||
|
<div class="letterhead">
|
||||||
|
<div class="sender-block">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="sender-company">{{SENDER_COMPANY}}</div>
|
||||||
|
<div class="sender-detail">{{SENDER_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sender-contact">{{SENDER_CONTACT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="letter-date">{{LETTER_DATE}}</div>
|
||||||
|
|
||||||
|
<!-- Recipient -->
|
||||||
|
<div class="recipient-block">
|
||||||
|
<div class="recipient-name">{{RECIPIENT_NAME}}</div>
|
||||||
|
<div class="recipient-detail">{{RECIPIENT_TITLE}}</div>
|
||||||
|
<div class="recipient-detail">{{RECIPIENT_COMPANY}}</div>
|
||||||
|
<div class="recipient-detail">{{RECIPIENT_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject -->
|
||||||
|
<div class="subject-line">
|
||||||
|
<span class="subject-label">Re:</span>{{SUBJECT_LINE}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Salutation -->
|
||||||
|
<div class="salutation">{{SALUTATION}}</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="body-text">{{BODY_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Closing -->
|
||||||
|
<div class="closing">{{CLOSING}}</div>
|
||||||
|
|
||||||
|
<!-- Signature -->
|
||||||
|
<div class="sig-block">
|
||||||
|
<div class="sig-name">{{SENDER_NAME}}</div>
|
||||||
|
<div class="sig-title">{{SENDER_TITLE}}</div>
|
||||||
|
<div class="sig-company">{{SENDER_COMPANY}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: enclosures / cc -->
|
||||||
|
<div class="letter-footer">
|
||||||
|
<div>Enc: {{ENCLOSURES}}</div>
|
||||||
|
<div>cc: {{CC_LIST}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Letter of Intent</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Proposed terms key-value ── */
|
||||||
|
.terms-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80pt auto;
|
||||||
|
row-gap: 4pt;
|
||||||
|
margin-top: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-label {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.terms-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{DOC_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Reference</div>
|
||||||
|
<div class="doc-number">{{LOI_REF}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Date / parties meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{LOI_DATE}}</span>
|
||||||
|
<span class="meta-label">From:</span> <span class="meta-value">{{PARTY_A_NAME}}</span>
|
||||||
|
<span class="meta-label">To:</span> <span class="meta-value">{{PARTY_B_NAME}}</span>
|
||||||
|
<span class="meta-label">Confidentiality:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties detail -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Party A</div>
|
||||||
|
<div class="party-name">{{PARTY_A_NAME}}</div>
|
||||||
|
<div class="party-detail">{{PARTY_A_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Party B</div>
|
||||||
|
<div class="party-name">{{PARTY_B_NAME}}</div>
|
||||||
|
<div class="party-detail">{{PARTY_B_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal sections -->
|
||||||
|
<div class="section-heading">Terms</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Purpose</div>
|
||||||
|
<div class="legal-section-body">{{PURPOSE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Proposed Terms (Summary)</div>
|
||||||
|
<div class="terms-grid">
|
||||||
|
<span class="terms-label">Scope:</span> <span class="terms-value">{{PROPOSED_SCOPE_TEXT}}</span>
|
||||||
|
<span class="terms-label">Commercials:</span> <span class="terms-value">{{COMMERCIAL_TERMS_TEXT}}</span>
|
||||||
|
<span class="terms-label">Timeline:</span> <span class="terms-value">{{TIMELINE_TEXT}}</span>
|
||||||
|
<span class="terms-label">Validity:</span> <span class="terms-value">{{VALIDITY_TEXT}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Non-Binding Status</div>
|
||||||
|
<div class="legal-section-body">{{NON_BINDING_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Binding Provisions (If Applicable)</div>
|
||||||
|
<div class="legal-section-body">{{BINDING_PROVISIONS_ITEMS}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">{{SIGNATURE_BLOCK_NOTE}}</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Party A</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{PARTY_A_SIGNER}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{PARTY_A_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Party B</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{PARTY_B_SIGNER}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{PARTY_B_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Letter of Intent — Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 60pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.terms-grid { display: grid; grid-template-columns: 80pt auto; row-gap: 4pt; margin-top: 4pt; }
|
||||||
|
.terms-label { font-size: 8.5pt; font-weight: 600; color: var(--theme-text-muted); }
|
||||||
|
.terms-value { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Letter of Intent</div>
|
||||||
|
<div class="doc-subtitle">Meridian Capital Advisors LLP</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Reference</div>
|
||||||
|
<div class="doc-number">LOI-2026-0011</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Confidentiality:</span> <span class="meta-value">CONFIDENTIAL</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">12 February 2026</span>
|
||||||
|
<span class="meta-label">From:</span> <span class="meta-value">Meridian Capital Advisors LLP</span>
|
||||||
|
<span class="meta-label">To:</span> <span class="meta-value">Trident Manufacturing Group Ltd</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Party A</div>
|
||||||
|
<div class="party-name">Meridian Capital Advisors LLP</div>
|
||||||
|
<div class="party-detail">45 Moorgate, London, EC2R 6AQ, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Party B</div>
|
||||||
|
<div class="party-name">Trident Manufacturing Group Ltd</div>
|
||||||
|
<div class="party-detail">Unit 12, Phoenix Industrial Estate, Sheffield, S9 2GR, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Terms</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Purpose</div>
|
||||||
|
<div class="legal-section-body">This Letter of Intent ("LOI") sets out the preliminary understanding between Party A and Party B regarding a proposed strategic investment by Party A in Party B, with a view to supporting Party B's expansion into European markets. This LOI is intended to outline the principal terms of the proposed transaction and to facilitate further due diligence and negotiation between the parties.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Proposed Terms (Summary)</div>
|
||||||
|
<div class="terms-grid">
|
||||||
|
<span class="terms-label">Scope:</span> <span class="terms-value">Minority equity investment by Party A in Party B of up to £3,500,000, representing approximately 18% of the fully diluted share capital post-investment.</span>
|
||||||
|
<span class="terms-label">Commercials:</span> <span class="terms-value">Valuation of Party B at £16,000,000 pre-money; investment structured as a primary subscription for new ordinary shares. Board observer right granted to Party A.</span>
|
||||||
|
<span class="terms-label">Timeline:</span> <span class="terms-value">Target completion within 8 weeks of signing this LOI, subject to satisfactory completion of due diligence and negotiation of definitive documentation.</span>
|
||||||
|
<span class="terms-label">Validity:</span> <span class="terms-value">This LOI expires on 14 March 2026 unless extended by written agreement of both parties.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Non-Binding Status</div>
|
||||||
|
<div class="legal-section-body">Except for the provisions expressly stated as binding below, this LOI does not constitute a legally binding agreement and does not create any obligation on either party to proceed with the proposed transaction. Either party may withdraw from negotiations at any time prior to execution of definitive transaction documents, without liability to the other party.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Binding Provisions (If Applicable)</div>
|
||||||
|
<div class="legal-section-body">The following provisions are intended to be legally binding on the parties:
|
||||||
|
- Confidentiality: Each party agrees to keep the existence and terms of this LOI, and all information exchanged in connection with the proposed transaction, strictly confidential for a period of twelve (12) months.
|
||||||
|
- Exclusivity: Party B agrees not to solicit, negotiate, or enter into any agreement regarding a competing transaction for a period of forty-five (45) days from the date of this LOI.
|
||||||
|
- Governing Law: This LOI shall be governed by the laws of England and Wales, and the parties submit to the exclusive jurisdiction of the courts of England and Wales in respect of the binding provisions.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">Agreed and acknowledged by authorised signatories of each party:</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Party A</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Jonathan Ashford</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Managing Partner</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Party B</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Diane Trevelyan</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Chief Executive Officer</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Business Letter</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.5; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.letterhead { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 8pt; border-bottom: 1.5pt solid var(--theme-accent); margin-bottom: 14pt; }
|
||||||
|
.sender-block { flex: 1; }
|
||||||
|
.sender-company { font-size: 14pt; font-weight: 700; color: var(--theme-primary); margin-bottom: 3pt; }
|
||||||
|
.sender-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.sender-contact { font-size: 8pt; color: var(--theme-text-muted); text-align: right; line-height: 1.6; }
|
||||||
|
.letter-date { font-size: 9.5pt; color: var(--theme-text); margin-bottom: 14pt; }
|
||||||
|
.recipient-block { margin-bottom: 14pt; }
|
||||||
|
.recipient-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); }
|
||||||
|
.recipient-detail { font-size: 9pt; color: var(--theme-text); line-height: 1.5; }
|
||||||
|
.subject-line { font-size: 9.5pt; font-weight: 700; color: var(--theme-primary); margin-bottom: 12pt; padding: 4pt 0; border-top: 0.5pt solid var(--theme-border); border-bottom: 0.5pt solid var(--theme-border); }
|
||||||
|
.subject-label { color: var(--theme-text-muted); font-weight: 600; font-size: 8.5pt; margin-right: 4pt; }
|
||||||
|
.salutation { font-size: 10pt; margin-bottom: 10pt; }
|
||||||
|
.body-text { font-size: 10pt; line-height: 1.6; color: var(--theme-text); white-space: pre-line; margin-bottom: 14pt; }
|
||||||
|
.closing { font-size: 10pt; margin-bottom: 30pt; }
|
||||||
|
.sig-block { font-size: 9.5pt; }
|
||||||
|
.sig-name { font-size: 10pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 1pt; }
|
||||||
|
.sig-title { font-size: 9pt; color: var(--theme-text-muted); margin-bottom: 1pt; }
|
||||||
|
.sig-company { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.letter-footer { margin-top: 16pt; padding-top: 6pt; border-top: 0.5pt solid var(--theme-border); font-size: 8pt; color: var(--theme-text-muted); line-height: 1.6; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="letterhead">
|
||||||
|
<div class="sender-block">
|
||||||
|
<div class="sender-company">Greenway Partners LLP</div>
|
||||||
|
<div class="sender-detail">7 Kings Cross Road, London, WC1X 9HB</div>
|
||||||
|
</div>
|
||||||
|
<div class="sender-contact">
|
||||||
|
Tel: +44 20 7946 0123<br>
|
||||||
|
[email protected]<br>
|
||||||
|
www.greenwaypartners.co.uk
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="letter-date">19 February 2026</div>
|
||||||
|
<div class="recipient-block">
|
||||||
|
<div class="recipient-name">Ms. Sarah Chen</div>
|
||||||
|
<div class="recipient-detail">Procurement Director</div>
|
||||||
|
<div class="recipient-detail">Titan Manufacturing Ltd</div>
|
||||||
|
<div class="recipient-detail">22 Industrial Estate, Sheffield, S1 4AB</div>
|
||||||
|
</div>
|
||||||
|
<div class="subject-line">
|
||||||
|
<span class="subject-label">Re:</span>Proposal for Supply Chain Consulting Services — Project Horizon
|
||||||
|
</div>
|
||||||
|
<div class="salutation">Dear Ms. Chen,</div>
|
||||||
|
<div class="body-text">Thank you for meeting with us on 12 February 2026 to discuss your supply chain optimisation challenges. We greatly appreciated the opportunity to learn about Titan Manufacturing's operations and the pressures you are facing as you scale production to meet growing European demand.
|
||||||
|
|
||||||
|
Following our discussions, we are pleased to submit this formal proposal for a twelve-week supply chain diagnostic and optimisation engagement. Our team will conduct an end-to-end review of your procurement, inventory, and logistics functions, benchmarking them against industry best practice and identifying measurable opportunities for cost reduction and lead-time improvement.
|
||||||
|
|
||||||
|
Based on our initial assessment, we anticipate delivering savings of between 8 and 14 per cent on total supply chain costs, with an expected payback period of under six months. We would mobilise a dedicated team of three senior consultants with direct sector experience in precision manufacturing and European distribution networks.
|
||||||
|
|
||||||
|
We would welcome the opportunity to present our detailed methodology and proposed workplan at your earliest convenience. Please do not hesitate to contact me directly should you require any further information or wish to arrange a follow-up meeting.</div>
|
||||||
|
<div class="closing">Yours sincerely,</div>
|
||||||
|
<div class="sig-block">
|
||||||
|
<div class="sig-name">Jonathan Rowe</div>
|
||||||
|
<div class="sig-title">Partner, Supply Chain Advisory</div>
|
||||||
|
<div class="sig-company">Greenway Partners LLP</div>
|
||||||
|
</div>
|
||||||
|
<div class="letter-footer">
|
||||||
|
<div>Enc: Project Horizon — Scope of Work Summary (2 pp.); Firm Credentials & Case Studies (6 pp.)</div>
|
||||||
|
<div>cc: Marcus Webb, Senior Manager, Greenway Partners LLP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Master Services Agreement</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.meta-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{DOC_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Reference</div>
|
||||||
|
<div class="doc-number">{{AGREEMENT_REF}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
<span class="meta-label">Provider:</span> <span class="meta-value">{{PROVIDER_NAME}}</span>
|
||||||
|
<span class="meta-label">Provider Address:</span> <span class="meta-value">{{PROVIDER_ADDRESS}}</span>
|
||||||
|
<span class="meta-label">Client:</span> <span class="meta-value">{{CLIENT_NAME}}</span>
|
||||||
|
<span class="meta-label">Client Address:</span> <span class="meta-value">{{CLIENT_ADDRESS}}</span>
|
||||||
|
<span class="meta-label">Confidentiality:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<div class="section-heading">Background</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-body">{{BACKGROUND_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal sections -->
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Scope of Services</div>
|
||||||
|
<div class="legal-section-body">{{SCOPE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Fees and Payment</div>
|
||||||
|
<div class="legal-section-body">{{FEES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Term and Termination</div>
|
||||||
|
<div class="legal-section-body">{{TERM_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">{{CONFIDENTIALITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Security / Data Handling</div>
|
||||||
|
<div class="legal-section-body">{{SECURITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">{{IP_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">{{LIABILITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Indemnification</div>
|
||||||
|
<div class="legal-section-body">{{INDEMNITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Governing Law</div>
|
||||||
|
<div class="legal-section-body">{{GOVERNING_LAW_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">{{SIGNATURE_NOTE}}</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Provider</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{PROVIDER_SIGNER}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{PROVIDER_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Client</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{CLIENT_SIGNER}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{CLIENT_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Master Services Agreement — Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Master Services Agreement</div>
|
||||||
|
<div class="doc-subtitle">Pinnacle Cloud Services Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Reference</div>
|
||||||
|
<div class="doc-number">MSA-2026-0008</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Confidentiality:</span> <span class="meta-value">CONFIDENTIAL</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">1 February 2026</span>
|
||||||
|
<span class="meta-label">Provider:</span> <span class="meta-value">Pinnacle Cloud Services Ltd</span>
|
||||||
|
<span class="meta-label">Provider Address:</span> <span class="meta-value">22 Finsbury Square, London, EC2A 1DX, United Kingdom</span>
|
||||||
|
<span class="meta-label">Client:</span> <span class="meta-value">Caldwell Retail Group plc</span>
|
||||||
|
<span class="meta-label">Client Address:</span> <span class="meta-value">100 Regent Street, London, W1B 5TB, United Kingdom</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Background</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-body">The Provider is a provider of cloud infrastructure, managed services, and professional technology services. The Client wishes to engage the Provider to deliver services in accordance with Statements of Work ("SOWs") entered into under this Agreement from time to time. This Agreement sets out the terms and conditions governing all such engagements.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Scope of Services</div>
|
||||||
|
<div class="legal-section-body">The Provider shall provide the services described in each SOW issued and executed under this Agreement. Each SOW shall form part of and be subject to this Agreement. In the event of conflict between this Agreement and a SOW, this Agreement shall prevail unless the SOW expressly states otherwise. The Provider may engage subcontractors to perform elements of the services, subject to the Client's written consent where the services involve access to personal data or confidential systems.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Fees and Payment</div>
|
||||||
|
<div class="legal-section-body">Fees for services shall be set out in each SOW. Unless otherwise specified: (a) invoices shall be issued monthly in arrears; (b) payment terms are thirty (30) days from invoice date; (c) all amounts are exclusive of VAT; and (d) late payments shall accrue interest at 4% per annum above the Bank of England base rate. The Provider reserves the right to suspend services upon sixty (60) days' written notice of non-payment.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Term and Termination</div>
|
||||||
|
<div class="legal-section-body">This Agreement shall commence on the Effective Date and continue until terminated by either party on ninety (90) days' written notice. Either party may terminate immediately on written notice if the other party: (a) commits a material breach not remedied within thirty (30) days of written notice; (b) becomes insolvent or enters administration or liquidation; or (c) undergoes a change of control without the other party's consent. Termination of this Agreement shall not affect any SOWs then in force unless the terminating party also terminates those SOWs in accordance with their terms.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">Each party ("Receiving Party") undertakes to keep confidential all information disclosed by the other party ("Disclosing Party") in connection with this Agreement that is marked confidential or that the Receiving Party knows or ought reasonably to know is confidential. The Receiving Party shall not disclose such information to third parties or use it except to perform this Agreement. This obligation shall not apply to information that is or becomes publicly available through no fault of the Receiving Party, or that is required to be disclosed by law or regulation.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Security / Data Handling</div>
|
||||||
|
<div class="legal-section-body">The Provider shall implement and maintain appropriate technical and organisational security measures to protect Client data against unauthorised access, disclosure, loss, or destruction, consistent with industry best practice. Where the Provider processes personal data on behalf of the Client, the parties shall enter into a data processing agreement compliant with the UK GDPR and any applicable data protection legislation.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">Each party retains ownership of its pre-existing intellectual property. Subject to full payment of fees, the Provider grants the Client a non-exclusive, non-transferable licence to use any deliverables produced under a SOW solely for the Client's internal business purposes. Unless otherwise agreed in a SOW, the Provider retains ownership of all proprietary tools, methodologies, and background IP used in delivering the services.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">Neither party shall be liable to the other for indirect, special, consequential, or punitive loss, including loss of profits, revenue, data, or goodwill, arising out of or in connection with this Agreement, even if advised of the possibility of such loss. Subject to the preceding sentence, each party's total aggregate liability under or in connection with this Agreement shall not exceed the greater of: (a) the total fees paid by the Client in the twelve (12) months preceding the event giving rise to the claim; or (b) £250,000. Nothing in this Agreement limits liability for death or personal injury caused by negligence, fraud, or any other liability that cannot be limited by law.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Indemnification</div>
|
||||||
|
<div class="legal-section-body">Each party ("Indemnifying Party") shall indemnify, defend, and hold harmless the other party and its officers, directors, and employees against any third-party claims, damages, costs, and expenses (including reasonable legal fees) arising from: (a) the Indemnifying Party's material breach of this Agreement; (b) the Indemnifying Party's gross negligence or wilful misconduct; or (c) in the case of the Provider, any infringement of a third party's intellectual property rights caused by the services or deliverables. Each party's indemnification obligations are subject to the limitation of liability provisions in Section 7.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Governing Law</div>
|
||||||
|
<div class="legal-section-body">This Agreement shall be governed by and construed in accordance with the laws of England and Wales. The parties irrevocably submit to the exclusive jurisdiction of the courts of England and Wales to resolve any dispute arising under or in connection with this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">Agreed by authorised signatories on behalf of each party:</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Provider</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Marcus Webb</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Chief Commercial Officer</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Client</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Rachel Caldwell</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Chief Technology Officer</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date:</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Meeting Minutes</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attendees table ── */
|
||||||
|
.attendees-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendees-table thead tr {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendees-table thead th {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-border, #e2e8f0);
|
||||||
|
font-size: 8pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendees-table tbody td {
|
||||||
|
padding: 3pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Discussion items ── */
|
||||||
|
.discussion-item {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
padding-left: 8pt;
|
||||||
|
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action items table ── */
|
||||||
|
.action-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-open { color: #b45309; font-weight: 600; }
|
||||||
|
.status-done { color: #15803d; font-weight: 600; }
|
||||||
|
.status-review { color: #1d4ed8; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Sign-off ── */
|
||||||
|
.signoff-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.discussion-item { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.signoff-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
.discussion-item { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{MEETING_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}} · Meeting Minutes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{MEETING_DATE}}</span>
|
||||||
|
<span class="meta-label">Time:</span> <span class="meta-value">{{MEETING_TIME}}</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">{{MEETING_LOCATION}}</span>
|
||||||
|
<span class="meta-label">Chairperson:</span><span class="meta-value">{{CHAIR_NAME}}</span>
|
||||||
|
<span class="meta-label">Minutes By:</span><span class="meta-value">{{SECRETARY_NAME}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Attendees -->
|
||||||
|
<div class="section-heading">Attendees</div>
|
||||||
|
<table class="attendees-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:35%">Name</th>
|
||||||
|
<th style="width:30%">Title / Role</th>
|
||||||
|
<th style="width:20%">Department</th>
|
||||||
|
<th style="width:15%">Present</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ATTENDEES_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Discussion -->
|
||||||
|
<div class="section-heading">Discussion</div>
|
||||||
|
{{DISCUSSION_ITEMS}}
|
||||||
|
|
||||||
|
<!-- Action Items -->
|
||||||
|
<div class="section-heading">Action Items</div>
|
||||||
|
<table class="action-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40%">Action Item</th>
|
||||||
|
<th style="width:20%">Owner</th>
|
||||||
|
<th style="width:20%">Due Date</th>
|
||||||
|
<th style="width:20%">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ACTION_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Next Meeting -->
|
||||||
|
<div class="section-heading">Next Meeting</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date & Time:</span> <span class="meta-value">{{NEXT_MEETING_DATE}}</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">{{NEXT_MEETING_LOCATION}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sign-off -->
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Prepared By</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{MINUTES_PREPARED_BY}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Approved By</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">{{CHAIR_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Meeting Minutes</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.attendees-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.attendees-table thead tr { background: var(--theme-surface); }
|
||||||
|
.attendees-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-border); font-size: 8pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.attendees-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); }
|
||||||
|
.discussion-item { margin-bottom: 8pt; padding-left: 8pt; border-left: 2pt solid var(--theme-accent); page-break-inside: avoid; }
|
||||||
|
.discussion-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.discussion-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.action-table { width: 100%; border-collapse: collapse; margin-bottom: 8pt; font-size: 8.5pt; }
|
||||||
|
.action-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.action-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.action-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.action-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.status-open { color: #b45309; font-weight: 600; }
|
||||||
|
.status-done { color: #15803d; font-weight: 600; }
|
||||||
|
.signoff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 14pt; }
|
||||||
|
.signoff-col { font-size: 8.5pt; }
|
||||||
|
.signoff-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 18pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .discussion-item { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Meeting Minutes</div>
|
||||||
|
<div class="doc-subtitle">Stirling Technologies Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">18 February 2026</span>
|
||||||
|
<span class="meta-label">Time:</span> <span class="meta-value">10:00 – 11:45</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">Conference Room B, London HQ</span>
|
||||||
|
<span class="meta-label">Chairperson:</span><span class="meta-value">James Whitfield</span>
|
||||||
|
<span class="meta-label">Minutes By:</span> <span class="meta-value">Amelia Frost</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Attendees</div>
|
||||||
|
<table class="attendees-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:35%">Name</th>
|
||||||
|
<th style="width:30%">Title / Role</th>
|
||||||
|
<th style="width:20%">Department</th>
|
||||||
|
<th style="width:15%">Present</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>James Whitfield</td><td>Chief Technology Officer</td><td>Engineering</td><td>Yes</td></tr>
|
||||||
|
<tr><td>Amelia Frost</td><td>Product Manager</td><td>Product</td><td>Yes</td></tr>
|
||||||
|
<tr><td>Rajan Mehta</td><td>Lead Engineer</td><td>Engineering</td><td>Yes</td></tr>
|
||||||
|
<tr><td>Sophie Langton</td><td>UX Designer</td><td>Design</td><td>Yes</td></tr>
|
||||||
|
<tr><td>Oliver Drummond</td><td>Head of Sales</td><td>Commercial</td><td>Yes</td></tr>
|
||||||
|
<tr><td>Niamh O'Brien</td><td>Data Analyst</td><td>Analytics</td><td>No (apologies sent)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading">Discussion</div>
|
||||||
|
<div class="discussion-item">
|
||||||
|
<div class="discussion-heading">1. Q1 Roadmap Review</div>
|
||||||
|
<div class="discussion-body">James opened with an overview of Q4 2025 delivery performance. Eight of nine planned features shipped on schedule; the smart-folder batch processing feature slipped by two weeks due to third-party API instability. The team agreed to carry this forward as a priority for Q1. Oliver noted positive customer feedback on the PDF merge redesign and requested it be highlighted in upcoming marketing materials.</div>
|
||||||
|
</div>
|
||||||
|
<div class="discussion-item">
|
||||||
|
<div class="discussion-heading">2. New Feature Prioritisation</div>
|
||||||
|
<div class="discussion-body">Amelia presented the backlog ranked by customer impact score. The top three items approved for Q1 development are: (1) AI-assisted document generation templates, (2) bulk processing queue with progress tracking, and (3) white-label branding options for enterprise clients. Sophie noted the design system update must be completed before white-label work begins and estimated a two-week lead time.</div>
|
||||||
|
</div>
|
||||||
|
<div class="discussion-item">
|
||||||
|
<div class="discussion-heading">3. Infrastructure and Performance</div>
|
||||||
|
<div class="discussion-body">Rajan presented benchmark results showing 23% improvement in PDF rendering speed following the January infrastructure upgrade. He proposed migrating the job queue to a dedicated worker cluster to support bulk processing. Estimated cost is £1,200/month; James approved provisionally pending finance sign-off. Target completion date: 14 March 2026.</div>
|
||||||
|
</div>
|
||||||
|
<div class="discussion-item">
|
||||||
|
<div class="discussion-heading">4. Q2 Planning Preview</div>
|
||||||
|
<div class="discussion-body">Brief discussion of Q2 themes: mobile application development and enhanced analytics dashboard. Oliver emphasised that the sales team urgently requires improved usage reporting for enterprise renewal conversations. Amelia to circulate a draft Q2 roadmap for async review by 25 February 2026.</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Action Items</div>
|
||||||
|
<table class="action-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:42%">Action Item</th>
|
||||||
|
<th style="width:20%">Owner</th>
|
||||||
|
<th style="width:20%">Due Date</th>
|
||||||
|
<th style="width:18%">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Finalise smart-folder batch processing spec and begin sprint</td><td>Rajan Mehta</td><td>25 Feb 2026</td><td class="status-open">Open</td></tr>
|
||||||
|
<tr><td>Complete design system update to unblock white-label work</td><td>Sophie Langton</td><td>4 Mar 2026</td><td class="status-open">Open</td></tr>
|
||||||
|
<tr><td>Submit infrastructure upgrade cost for finance approval</td><td>James Whitfield</td><td>21 Feb 2026</td><td class="status-open">Open</td></tr>
|
||||||
|
<tr><td>Circulate draft Q2 roadmap for team review</td><td>Amelia Frost</td><td>25 Feb 2026</td><td class="status-open">Open</td></tr>
|
||||||
|
<tr><td>Prepare PDF merge feature highlights for marketing</td><td>Oliver Drummond</td><td>28 Feb 2026</td><td class="status-open">Open</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading">Next Meeting</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Date & Time:</span> <span class="meta-value">18 March 2026, 10:00</span>
|
||||||
|
<span class="meta-label">Location:</span> <span class="meta-value">Conference Room B, London HQ</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<div class="signoff-grid">
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Prepared By</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Amelia Frost</div>
|
||||||
|
</div>
|
||||||
|
<div class="signoff-col">
|
||||||
|
<div class="signoff-label">Approved By</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">James Whitfield</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Non-Disclosure Agreement</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Party section (plain two-column, no card/border) ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{AGREEMENT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">Non-Disclosure Agreement</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">{{GOVERNING_LAW}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Disclosing Party</div>
|
||||||
|
<div class="party-name">{{DISCLOSING_PARTY_NAME}}</div>
|
||||||
|
<div class="party-detail">{{DISCLOSING_PARTY_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Receiving Party</div>
|
||||||
|
<div class="party-name">{{RECEIVING_PARTY_NAME}}</div>
|
||||||
|
<div class="party-detail">{{RECEIVING_PARTY_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal Sections -->
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Purpose</div>
|
||||||
|
<div class="legal-section-body">{{PURPOSE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Definitions</div>
|
||||||
|
<div class="legal-section-body">{{DEFINITION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Obligations of Receiving Party</div>
|
||||||
|
<div class="legal-section-body">{{OBLIGATIONS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Exclusions</div>
|
||||||
|
<div class="legal-section-body">{{EXCLUSIONS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Term</div>
|
||||||
|
<div class="legal-section-body">{{TERM_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Remedies</div>
|
||||||
|
<div class="legal-section-body">{{REMEDIES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Return of Information</div>
|
||||||
|
<div class="legal-section-body">{{RETURN_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">{{INTELLECTUAL_PROPERTY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. No Licence</div>
|
||||||
|
<div class="legal-section-body">{{NO_LICENSE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. Waiver</div>
|
||||||
|
<div class="legal-section-body">{{WAIVER_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">11. Severability</div>
|
||||||
|
<div class="legal-section-body">{{SEVERABILITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">12. Entire Agreement</div>
|
||||||
|
<div class="legal-section-body">{{ENTIRE_AGREEMENT_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">13. Amendments</div>
|
||||||
|
<div class="legal-section-body">{{AMENDMENTS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">14. Notices</div>
|
||||||
|
<div class="legal-section-body">{{NOTICES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Disclosing Party</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{DISCLOSING_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{DISCLOSING_SIGNER_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{DISCLOSING_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Receiving Party</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{RECEIVING_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{RECEIVING_SIGNER_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{RECEIVING_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Non-Disclosure Agreement</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Non-Disclosure Agreement</div>
|
||||||
|
<div class="doc-subtitle">Mutual NDA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">1 February 2026</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">England and Wales</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Disclosing Party</div>
|
||||||
|
<div class="party-name">Kestrel Technologies Ltd</div>
|
||||||
|
<div class="party-detail">14 Innovation Drive, Cambridge, CB1 3AA, UK</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Receiving Party</div>
|
||||||
|
<div class="party-name">Moorfield Capital Partners LLP</div>
|
||||||
|
<div class="party-detail">3 King Street, London, EC2V 8BD, UK</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Purpose</div>
|
||||||
|
<div class="legal-section-body">The parties wish to explore a potential commercial collaboration relating to the development and commercialisation of proprietary software products. Each party may disclose Confidential Information to the other solely for the purpose of evaluating this potential partnership.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Definitions</div>
|
||||||
|
<div class="legal-section-body">"Confidential Information" means any information disclosed by one party to the other, whether orally, in writing, or by any other means, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and the circumstances of disclosure.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Obligations of Receiving Party</div>
|
||||||
|
<div class="legal-section-body">The Receiving Party shall: (a) hold all Confidential Information in strict confidence; (b) not disclose Confidential Information to any third party without the prior written consent of the Disclosing Party; (c) use Confidential Information solely for the Purpose; and (d) protect Confidential Information using at least the same degree of care it uses to protect its own confidential information, but in no event less than reasonable care.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Exclusions</div>
|
||||||
|
<div class="legal-section-body">Obligations do not apply to information that: (a) is or becomes publicly known through no breach of this Agreement; (b) was rightfully in the Receiving Party's possession before disclosure; (c) is independently developed by the Receiving Party without use of Confidential Information; or (d) is required to be disclosed by law, regulation, or court order.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Term</div>
|
||||||
|
<div class="legal-section-body">This Agreement shall remain in effect for three (3) years from the Effective Date, unless earlier terminated by either party on thirty (30) days' written notice. Confidentiality obligations shall survive termination for a further two (2) years.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Remedies</div>
|
||||||
|
<div class="legal-section-body">Each party acknowledges that breach of this Agreement may cause irreparable harm for which monetary damages would be an inadequate remedy. Accordingly, the Disclosing Party shall be entitled to seek injunctive or other equitable relief in addition to all other remedies available at law.</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Disclosing Party</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Jonathan Kestrel</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Chief Executive Officer</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 1 February 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Receiving Party</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Priya Moorfield</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Managing Partner</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 1 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offer of Employment</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Candidate name ── */
|
||||||
|
.candidate-name {
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin: 10pt 0 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-para {
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 3pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-left: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Acceptance deadline ── */
|
||||||
|
.deadline-line {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin: 8pt 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{DOC_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Candidate -->
|
||||||
|
<div class="candidate-name">{{CANDIDATE_NAME}}</div>
|
||||||
|
<div class="opening-para">We are delighted to extend this offer of employment and look forward to welcoming you to our team.</div>
|
||||||
|
|
||||||
|
<!-- Role Details -->
|
||||||
|
<div class="section-heading">Role Details</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Position:</span> <span class="meta-value">{{POSITION_TITLE}}</span>
|
||||||
|
<span class="meta-label">Department:</span> <span class="meta-value">{{DEPARTMENT}}</span>
|
||||||
|
<span class="meta-label">Start Date:</span> <span class="meta-value">{{START_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compensation -->
|
||||||
|
<div class="section-heading">Compensation</div>
|
||||||
|
<div class="text-section">{{COMPENSATION_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Benefits -->
|
||||||
|
<div class="section-heading">Benefits</div>
|
||||||
|
<div class="text-section">{{BENEFITS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Terms -->
|
||||||
|
<div class="section-heading">Terms and Conditions</div>
|
||||||
|
<div class="text-section">{{TERMS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Acceptance -->
|
||||||
|
<div class="deadline-line">Please sign and return this letter by <strong>{{ACCEPTANCE_DEADLINE}}</strong> to confirm your acceptance of this offer.</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">On Behalf of {{ORG_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{SIGNER_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Candidate Acceptance</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{CANDIDATE_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offer of Employment</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.candidate-name { font-size: 16pt; font-weight: 700; color: var(--theme-heading); margin: 10pt 0 4pt; }
|
||||||
|
.opening-para { font-size: 10pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 8pt; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 3pt; margin-bottom: 8pt; border-left: 2pt solid var(--theme-accent); padding-left: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 9pt; }
|
||||||
|
.meta-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 9.5pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 4pt; }
|
||||||
|
.deadline-line { font-size: 9pt; color: var(--theme-text); margin: 8pt 0; font-style: italic; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Offer Letter</div>
|
||||||
|
<div class="doc-subtitle">Redwood Analytics Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="candidate-name">Sophie Hargreaves</div>
|
||||||
|
<div class="opening-para">We are delighted to extend this offer of employment and look forward to welcoming you to our team.</div>
|
||||||
|
<div class="section-heading">Role Details</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Position:</span> <span class="meta-value">Senior Data Analyst</span>
|
||||||
|
<span class="meta-label">Department:</span> <span class="meta-value">Product Intelligence</span>
|
||||||
|
<span class="meta-label">Start Date:</span> <span class="meta-value">2 March 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Compensation</div>
|
||||||
|
<div class="text-section">Your annual base salary will be £58,000, paid monthly in arrears on the last working day of each month. You will be eligible for an annual performance bonus of up to 15% of your base salary, subject to the achievement of agreed targets. A salary review will take place each April, with any adjustment effective from 1 April.</div>
|
||||||
|
<div class="section-heading">Benefits</div>
|
||||||
|
<div class="text-section">You will be entitled to 25 days' annual leave per year, plus UK public holidays. The company operates a contributory pension scheme (employer contribution: 5%, employee minimum: 3%). Additional benefits include private health insurance (BUPA), a cycle-to-work scheme, and access to our employee assistance programme.</div>
|
||||||
|
<div class="section-heading">Terms and Conditions</div>
|
||||||
|
<div class="text-section">Employment is subject to satisfactory completion of a three-month probationary period, receipt of two satisfactory references, and evidence of your right to work in the United Kingdom. This offer is also conditional on a satisfactory Disclosure and Barring Service (DBS) check where applicable. Full terms and conditions are set out in the contract of employment enclosed with this letter.</div>
|
||||||
|
<div class="deadline-line">Please sign and return this letter by <strong>28 February 2026</strong> to confirm your acceptance of this offer.</div>
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">On Behalf of Redwood Analytics Ltd</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Marcus Webb</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Head of People & Culture</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Candidate Acceptance</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Sophie Hargreaves</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Memorandum</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80pt auto;
|
||||||
|
row-gap: 4pt;
|
||||||
|
margin: 10pt 0;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-bottom: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
padding: 8pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body text ── */
|
||||||
|
.memo-body {
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Memorandum</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">To:</span> <span class="meta-value">{{MEMO_TO}}</span>
|
||||||
|
<span class="meta-label">From:</span> <span class="meta-value">{{MEMO_FROM}}</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{MEMO_DATE}}</span>
|
||||||
|
<span class="meta-label">Subject:</span> <span class="meta-value">{{MEMO_SUBJECT}}</span>
|
||||||
|
<span class="meta-label">CC:</span> <span class="meta-value">{{MEMO_CC}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="memo-body">{{MEMO_BODY}}</div>
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div class="section-heading">Attachments</div>
|
||||||
|
<div class="text-section">{{MEMO_ATTACHMENTS}}</div>
|
||||||
|
|
||||||
|
<!-- Closing -->
|
||||||
|
<div class="section-heading">Closing</div>
|
||||||
|
<div class="text-section">{{MEMO_CLOSING}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Memorandum</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 80pt auto; row-gap: 4pt; margin: 10pt 0; border-top: 0.5pt solid var(--theme-border); border-bottom: 0.5pt solid var(--theme-border); padding: 8pt 0; }
|
||||||
|
.meta-label { font-weight: 700; font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.meta-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.memo-body { font-size: 10pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.text-section { font-size: 8.5pt; line-height: 1.5; margin-bottom: 4pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Memorandum</div>
|
||||||
|
<div class="doc-subtitle">Ashford & Partners LLP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">To:</span> <span class="meta-value">All Senior Associates and Partners</span>
|
||||||
|
<span class="meta-label">From:</span> <span class="meta-value">Catherine Ashford, Managing Partner</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">19 February 2026</span>
|
||||||
|
<span class="meta-label">Subject:</span> <span class="meta-value">Updated Remote Working Policy — Effective 1 March 2026</span>
|
||||||
|
<span class="meta-label">CC:</span> <span class="meta-value">HR Department; Office Manager</span>
|
||||||
|
</div>
|
||||||
|
<div class="memo-body">Following the firm-wide review conducted in January, we are pleased to confirm a revised remote working policy that will take effect on 1 March 2026.
|
||||||
|
|
||||||
|
All fee-earners and support staff at Band 3 and above are eligible to work remotely for up to two days per week, provided that client-facing commitments and team meeting schedules are maintained. Requests must be agreed with your supervising partner at least one week in advance.
|
||||||
|
|
||||||
|
Please be aware that the following expectations remain unchanged regardless of work location: billable hour targets, client response times (within four business hours), and mandatory attendance at the monthly all-hands meeting held on the first Monday of each month.
|
||||||
|
|
||||||
|
Detailed guidance, including the updated remote-working agreement form, is available on the intranet under HR > Policies > Remote Working. Please complete and return the form to HR no later than 21 February 2026.
|
||||||
|
|
||||||
|
Questions should be directed to Gemma Clarke in Human Resources.</div>
|
||||||
|
<div class="section-heading">Attachments</div>
|
||||||
|
<div class="text-section">1. Remote Working Agreement Form (Rev. 3, Feb 2026) 2. IT Security Guidelines for Remote Access</div>
|
||||||
|
<div class="section-heading">Closing</div>
|
||||||
|
<div class="text-section">We appreciate your cooperation and look forward to a smooth transition. Please do not hesitate to contact HR if you have any questions.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pay Stub</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Employee info card ── */
|
||||||
|
.employee-card {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-radius: 3pt;
|
||||||
|
padding: 8pt 10pt;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4pt 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8pt;
|
||||||
|
min-width: 76pt;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 0 0 5pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pay columns ── */
|
||||||
|
.pay-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14pt;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-col-inner {
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pay tables ── */
|
||||||
|
.pay-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8pt;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table thead th {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.pay-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table tbody td {
|
||||||
|
padding: 3pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table tfoot tr {
|
||||||
|
border-top: 1pt solid var(--theme-border, #e2e8f0);
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table tfoot td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-table tfoot td.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Net pay banner ── */
|
||||||
|
.net-pay-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 9pt 14pt;
|
||||||
|
margin: 0 0 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.net-pay-label {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.net-pay-amount {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── YTD summary ── */
|
||||||
|
.ytd-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytd-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytd-table thead th {
|
||||||
|
padding: 4pt 8pt;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 8pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytd-table thead th:first-child { text-align: left; }
|
||||||
|
|
||||||
|
.ytd-table tbody td {
|
||||||
|
padding: 4pt 8pt;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytd-table tbody td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Payment details ── */
|
||||||
|
.payment-info {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.net-pay-box { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.pay-cols { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.ytd-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.employee-card { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Pay Stub</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Employee Information -->
|
||||||
|
<div class="employee-card">
|
||||||
|
<div class="employee-card-grid">
|
||||||
|
<div class="info-row"><span class="info-label">Employee Name:</span><span class="info-value">{{EMPLOYEE_NAME}}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Pay Period:</span><span class="info-value">{{PAY_PERIOD_START}} – {{PAY_PERIOD_END}}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Employee ID:</span><span class="info-value">{{EMPLOYEE_ID}}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Pay Date:</span><span class="info-value">{{PAY_DATE}}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Department:</span><span class="info-value">{{DEPARTMENT}}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Position:</span><span class="info-value">{{POSITION}}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Earnings & Deductions -->
|
||||||
|
<div class="pay-cols">
|
||||||
|
<div class="pay-col-inner">
|
||||||
|
<div class="section-heading">Earnings</div>
|
||||||
|
<table class="pay-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num">Hours</th>
|
||||||
|
<th class="num">Rate</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{EARNINGS_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">Gross Pay</td>
|
||||||
|
<td class="num">{{GROSS_PAY}}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pay-col-inner">
|
||||||
|
<div class="section-heading">Deductions</div>
|
||||||
|
<table class="pay-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{DEDUCTIONS_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>Total Deductions</td>
|
||||||
|
<td class="num">{{TOTAL_DEDUCTIONS}}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Net Pay -->
|
||||||
|
<div class="net-pay-box">
|
||||||
|
<div class="net-pay-label">Net Pay</div>
|
||||||
|
<div class="net-pay-amount">{{NET_PAY}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YTD Summary -->
|
||||||
|
<div class="section-heading" style="margin-top: 10pt">Year-to-Date Summary</div>
|
||||||
|
<table class="ytd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Gross Pay</th>
|
||||||
|
<th>Total Deductions</th>
|
||||||
|
<th>Net Pay</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>YTD</td>
|
||||||
|
<td>{{YTD_GROSS}}</td>
|
||||||
|
<td>{{YTD_DEDUCTIONS}}</td>
|
||||||
|
<td>{{YTD_NET}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Payment Details -->
|
||||||
|
<div class="section-heading" style="margin-top: 10pt">Payment Details</div>
|
||||||
|
<div class="payment-info">{{PAYMENT_METHOD}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pay Stub</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.employee-card { background: var(--theme-surface); border: 0.5pt solid var(--theme-border); border-radius: 3pt; padding: 8pt 10pt; margin-bottom: 10pt; }
|
||||||
|
.employee-card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4pt 20pt; }
|
||||||
|
.info-row { display: flex; gap: 4pt; }
|
||||||
|
.info-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8pt; min-width: 76pt; flex-shrink: 0; }
|
||||||
|
.info-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 0 0 5pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.pay-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14pt; margin-bottom: 0; }
|
||||||
|
.pay-col-inner { margin-bottom: 10pt; }
|
||||||
|
.pay-table { width: 100%; border-collapse: collapse; font-size: 8pt; }
|
||||||
|
.pay-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.pay-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 600; font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.pay-table thead th.num { text-align: right; }
|
||||||
|
.pay-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.pay-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.pay-table tbody td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.pay-table tfoot tr { border-top: 1pt solid var(--theme-border); background: var(--theme-surface); }
|
||||||
|
.pay-table tfoot td { padding: 4pt 6pt; font-weight: 700; font-size: 8.5pt; color: var(--theme-heading); }
|
||||||
|
.pay-table tfoot td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.net-pay-box { display: flex; justify-content: space-between; align-items: center; background: var(--theme-primary); color: #fff; padding: 9pt 14pt; margin: 0 0 10pt; }
|
||||||
|
.net-pay-label { font-size: 11pt; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; }
|
||||||
|
.net-pay-amount { font-size: 20pt; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.ytd-table { width: 100%; border-collapse: collapse; font-size: 8.5pt; margin-bottom: 8pt; }
|
||||||
|
.ytd-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.ytd-table thead th { padding: 4pt 8pt; text-align: right; font-weight: 600; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.ytd-table thead th:first-child { text-align: left; }
|
||||||
|
.ytd-table tbody td { padding: 4pt 8pt; text-align: right; border-bottom: 0.3pt solid var(--theme-border); font-variant-numeric: tabular-nums; }
|
||||||
|
.ytd-table tbody td:first-child { text-align: left; font-weight: 600; color: var(--theme-text-muted); }
|
||||||
|
.payment-info { font-size: 8.5pt; color: var(--theme-text); line-height: 1.6; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Pay Stub</div>
|
||||||
|
<div class="doc-subtitle">Meridian Technologies Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="employee-card">
|
||||||
|
<div class="employee-card-grid">
|
||||||
|
<div class="info-row"><span class="info-label">Employee Name:</span><span class="info-value">Sarah J. Mitchell</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Pay Period:</span><span class="info-value">1 February 2026 – 28 February 2026</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Employee ID:</span><span class="info-value">EMP-0142</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Pay Date:</span><span class="info-value">28 February 2026</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Department:</span><span class="info-value">Engineering</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Position:</span><span class="info-value">Senior Software Engineer</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pay-cols">
|
||||||
|
<div class="pay-col-inner">
|
||||||
|
<div class="section-heading">Earnings</div>
|
||||||
|
<table class="pay-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num">Hours</th>
|
||||||
|
<th class="num">Rate</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Basic Salary</td>
|
||||||
|
<td class="num">160</td>
|
||||||
|
<td class="num">£30.21</td>
|
||||||
|
<td class="num">£4,833.33</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Overtime</td>
|
||||||
|
<td class="num">8</td>
|
||||||
|
<td class="num">£45.31</td>
|
||||||
|
<td class="num">£362.50</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>On-Call Allowance</td>
|
||||||
|
<td class="num">—</td>
|
||||||
|
<td class="num">—</td>
|
||||||
|
<td class="num">£150.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">Gross Pay</td>
|
||||||
|
<td class="num">£5,345.83</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pay-col-inner">
|
||||||
|
<div class="section-heading">Deductions</div>
|
||||||
|
<table class="pay-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Income Tax (PAYE)</td>
|
||||||
|
<td class="num">£1,156.60</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>National Insurance</td>
|
||||||
|
<td class="num">£455.62</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pension (Employee 5%)</td>
|
||||||
|
<td class="num">£267.29</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Health Insurance</td>
|
||||||
|
<td class="num">£85.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>Total Deductions</td>
|
||||||
|
<td class="num">£1,964.51</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="net-pay-box">
|
||||||
|
<div class="net-pay-label">Net Pay</div>
|
||||||
|
<div class="net-pay-amount">£3,381.32</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading" style="margin-top:10pt">Year-to-Date Summary</div>
|
||||||
|
<table class="ytd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Gross Pay</th>
|
||||||
|
<th>Total Deductions</th>
|
||||||
|
<th>Net Pay</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>YTD</td>
|
||||||
|
<td>£10,524.16</td>
|
||||||
|
<td>£3,871.42</td>
|
||||||
|
<td>£6,652.74</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading" style="margin-top:10pt">Payment Details</div>
|
||||||
|
<div class="payment-info">Payment Method: BACS Direct Credit<br>Bank: Lloyds Bank plc | Sort Code: 30-00-00 | Account: ****4821</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Performance Review</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Review info grid ── */
|
||||||
|
.review-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-cell {
|
||||||
|
padding: 4pt 7pt;
|
||||||
|
border-bottom: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-right: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-cell:nth-child(even) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-cell:nth-last-child(-n+2) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 7.5pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
margin-bottom: 1pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-value {
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rating scale note ── */
|
||||||
|
.rating-scale-note {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Competency table ── */
|
||||||
|
.competency-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competency-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competency-table thead th {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competency-table thead th.center { text-align: center; }
|
||||||
|
|
||||||
|
.competency-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.competency-table tbody td {
|
||||||
|
padding: 3pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competency-table tbody td.center { text-align: center; }
|
||||||
|
|
||||||
|
/* ── Overall rating box ── */
|
||||||
|
.overall-rating-box {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 5pt 14pt;
|
||||||
|
border-radius: 2pt;
|
||||||
|
margin: 4pt 0 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ack-note {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Employee Performance Review</div>
|
||||||
|
<div class="doc-subtitle">{{ORG_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Review Info Grid -->
|
||||||
|
<div class="section-heading">Review Information</div>
|
||||||
|
<div class="review-grid">
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Employee Name</div>
|
||||||
|
<div class="cell-value">{{EMPLOYEE_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Employee ID</div>
|
||||||
|
<div class="cell-value">{{EMPLOYEE_ID}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Job Title</div>
|
||||||
|
<div class="cell-value">{{JOB_TITLE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Department</div>
|
||||||
|
<div class="cell-value">{{DEPARTMENT}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Manager</div>
|
||||||
|
<div class="cell-value">{{MANAGER_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Review Period</div>
|
||||||
|
<div class="cell-value">{{REVIEW_PERIOD_START}} to {{REVIEW_PERIOD_END}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating Scale -->
|
||||||
|
<div class="rating-scale-note">{{RATING_SCALE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Goals & Outcomes -->
|
||||||
|
<div class="section-heading">Goals and Outcomes</div>
|
||||||
|
<div class="text-section"><strong>Goals:</strong><br>{{GOALS_TEXT}}</div>
|
||||||
|
<div class="text-section"><strong>Outcomes:</strong><br>{{GOAL_OUTCOMES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Competencies Table -->
|
||||||
|
<div class="section-heading">Competencies and Ratings</div>
|
||||||
|
<table class="competency-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:45%">Competency</th>
|
||||||
|
<th class="center" style="width:15%">Rating (1–5)</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{COMPETENCY_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Key Accomplishments -->
|
||||||
|
<div class="section-heading">Key Accomplishments</div>
|
||||||
|
<div class="text-section">{{ACCOMPLISHMENTS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Areas for Improvement -->
|
||||||
|
<div class="section-heading">Areas for Improvement</div>
|
||||||
|
<div class="text-section">{{IMPROVEMENT_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Development Plan -->
|
||||||
|
<div class="section-heading">Development Plan</div>
|
||||||
|
<div class="text-section">{{DEVELOPMENT_PLAN_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Overall Rating -->
|
||||||
|
<div class="section-heading">Overall Rating</div>
|
||||||
|
<div class="overall-rating-box">{{OVERALL_RATING}}</div>
|
||||||
|
|
||||||
|
<!-- Employee Comments -->
|
||||||
|
<div class="section-heading">Employee Comments</div>
|
||||||
|
<div class="text-section">{{EMPLOYEE_COMMENTS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Manager Comments -->
|
||||||
|
<div class="section-heading">Manager Comments</div>
|
||||||
|
<div class="text-section">{{MANAGER_COMMENTS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Acknowledgement</div>
|
||||||
|
<div class="ack-note">Signatures below acknowledge receipt and discussion of this review. Signature does not necessarily indicate agreement with all conclusions.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Manager</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{MANAGER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{MANAGER_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{EMPLOYEE_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{EMPLOYEE_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Performance Review</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.review-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 0.5pt solid var(--theme-border); margin-bottom: 10pt; font-size: 8.5pt; }
|
||||||
|
.review-cell { padding: 4pt 7pt; border-bottom: 0.5pt solid var(--theme-border); border-right: 0.5pt solid var(--theme-border); }
|
||||||
|
.review-cell:nth-child(even) { border-right: none; }
|
||||||
|
.review-cell:nth-last-child(-n+2) { border-bottom: none; }
|
||||||
|
.cell-label { font-weight: 600; color: var(--theme-text-muted); font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 1pt; }
|
||||||
|
.cell-value { color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.rating-scale-note { font-size: 8pt; color: var(--theme-text-muted); font-style: italic; margin-bottom: 6pt; padding: 4pt 6pt; background: var(--theme-surface); border-left: 2pt solid var(--theme-accent); }
|
||||||
|
.text-section { font-size: 9.5pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.competency-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8pt; }
|
||||||
|
.competency-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.competency-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.competency-table thead th.center { text-align: center; }
|
||||||
|
.competency-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.competency-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.competency-table tbody td.center { text-align: center; }
|
||||||
|
.overall-rating-box { display: inline-block; background: var(--theme-primary); color: #fff; font-size: 11pt; font-weight: 700; padding: 5pt 14pt; border-radius: 2pt; margin: 4pt 0 8pt; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
.ack-note { font-size: 8.5pt; font-style: italic; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Performance Review</div>
|
||||||
|
<div class="doc-subtitle">Cavendish Financial Solutions Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Review Information</div>
|
||||||
|
<div class="review-grid">
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Employee Name</div>
|
||||||
|
<div class="cell-value">Priya Nair</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Employee ID</div>
|
||||||
|
<div class="cell-value">EMP-2847</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Job Title</div>
|
||||||
|
<div class="cell-value">Senior Business Analyst</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Department</div>
|
||||||
|
<div class="cell-value">Strategy & Operations</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Manager</div>
|
||||||
|
<div class="cell-value">Jonathan Ashby</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-cell">
|
||||||
|
<div class="cell-label">Review Period</div>
|
||||||
|
<div class="cell-value">1 January 2025 to 31 December 2025</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rating-scale-note">Rating scale: 1 = Does Not Meet Expectations | 2 = Partially Meets Expectations | 3 = Meets Expectations | 4 = Exceeds Expectations | 5 = Outstanding</div>
|
||||||
|
<div class="section-heading">Goals and Outcomes</div>
|
||||||
|
<div class="text-section"><strong>Goals:</strong><br>1. Lead the migration of legacy reporting infrastructure to the new data platform by Q3 2025.
|
||||||
|
2. Achieve a client satisfaction score of 4.5 or above across assigned accounts.
|
||||||
|
3. Complete the Chartered Business Analysis Professional (CBAP) certification.</div>
|
||||||
|
<div class="text-section"><strong>Outcomes:</strong><br>The data platform migration was completed two weeks ahead of schedule in August 2025, with zero production incidents during cutover. Client satisfaction scores averaged 4.7 across all assigned accounts. Priya successfully attained her CBAP certification in October 2025.</div>
|
||||||
|
<div class="section-heading">Competencies and Ratings</div>
|
||||||
|
<table class="competency-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:45%">Competency</th>
|
||||||
|
<th class="center" style="width:15%">Rating (1–5)</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Problem Solving & Analysis</td>
|
||||||
|
<td class="center">5</td>
|
||||||
|
<td>Consistently applies structured methodologies; proactively identifies root causes.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Communication & Stakeholder Management</td>
|
||||||
|
<td class="center">4</td>
|
||||||
|
<td>Clear and concise communicator; builds strong relationships across all levels.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Delivery & Results Orientation</td>
|
||||||
|
<td class="center">5</td>
|
||||||
|
<td>Delivered all major workstreams on time or ahead of schedule throughout the year.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Teamwork & Collaboration</td>
|
||||||
|
<td class="center">4</td>
|
||||||
|
<td>A valued team member who readily supports colleagues and shares knowledge.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Adaptability & Learning</td>
|
||||||
|
<td class="center">5</td>
|
||||||
|
<td>Embraced new tooling and methodology changes with notable enthusiasm.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading">Key Accomplishments</div>
|
||||||
|
<div class="text-section">Successfully led a cross-functional team of eight in delivering the data platform migration, generating an estimated annual saving of £180,000 in licensing and maintenance costs. Authored a new requirements management framework that has since been adopted company-wide. Mentored two junior analysts, both of whom achieved promotion to Analyst II during the review period.</div>
|
||||||
|
<div class="section-heading">Areas for Improvement</div>
|
||||||
|
<div class="text-section">Delegation: Priya occasionally takes on too much individually rather than distributing work effectively across the team. Developing stronger delegation habits will be critical as she moves towards a lead role.
|
||||||
|
|
||||||
|
Presentation skills: While written communication is excellent, further development of executive-level presentation delivery would strengthen impact with senior stakeholders.</div>
|
||||||
|
<div class="section-heading">Development Plan</div>
|
||||||
|
<div class="text-section">1. Attend the internal Leadership Essentials programme (Q1 2026).
|
||||||
|
2. Co-present at the annual All-Hands strategy review in March 2026 alongside the Head of Strategy.
|
||||||
|
3. Shadow the Delivery Director on at least two client pitches before Q2 2026.
|
||||||
|
4. Set up a fortnightly one-to-one with direct reports to strengthen delegation practices.</div>
|
||||||
|
<div class="section-heading">Overall Rating</div>
|
||||||
|
<div class="overall-rating-box">4.6 — Exceeds Expectations</div>
|
||||||
|
<div class="section-heading">Employee Comments</div>
|
||||||
|
<div class="text-section">I am proud of what the team achieved with the platform migration this year and grateful for the support and autonomy Jonathan provided throughout. I welcome the feedback on delegation and will prioritise the Leadership Essentials programme. I am excited about the development opportunities outlined for 2026.</div>
|
||||||
|
<div class="section-heading">Manager Comments</div>
|
||||||
|
<div class="text-section">Priya has had an outstanding year and is one of the most effective contributors in the Strategy & Operations team. Her technical depth, client focus, and commitment to professional development set an excellent example. I look forward to supporting her continued growth into a senior leadership position in 2026.</div>
|
||||||
|
<div class="section-heading">Acknowledgement</div>
|
||||||
|
<div class="ack-note">Signatures below acknowledge receipt and discussion of this review. Signature does not necessarily indicate agreement with all conclusions.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Manager</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Jonathan Ashby</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 15 January 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Priya Nair</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 16 January 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Price Sheet</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta info grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Price table ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table .desc-col { width: 38%; }
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{PRICE_SHEET_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Effective Date</div>
|
||||||
|
<div class="doc-number">{{EFFECTIVE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Currency:</span> <span class="meta-value">{{CURRENCY}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Pricing Table -->
|
||||||
|
<div class="section-heading">Pricing</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:26pt">SKU</th>
|
||||||
|
<th class="desc-col">Description</th>
|
||||||
|
<th style="width:48pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{PRICE_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">{{NOTES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Terms -->
|
||||||
|
<div class="section-heading">Terms</div>
|
||||||
|
<div class="text-section">{{TERMS_TEXT}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Price Sheet</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.text-section { font-size: 8.5pt; line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Price Sheet</div>
|
||||||
|
<div class="doc-subtitle">Vantage Cloud Services Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Effective Date</div>
|
||||||
|
<div class="doc-number">1 March 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Currency:</span> <span class="meta-value">GBP (£) — All prices exclude VAT</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Pricing</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:26pt">SKU</th>
|
||||||
|
<th style="width:38%">Description</th>
|
||||||
|
<th style="width:48pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-001</td>
|
||||||
|
<td>Starter Plan — up to 5 users</td>
|
||||||
|
<td>per month</td>
|
||||||
|
<td class="num">£49.00</td>
|
||||||
|
<td>Billed monthly; cancel anytime</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-002</td>
|
||||||
|
<td>Growth Plan — up to 25 users</td>
|
||||||
|
<td>per month</td>
|
||||||
|
<td class="num">£149.00</td>
|
||||||
|
<td>Includes advanced analytics</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-003</td>
|
||||||
|
<td>Business Plan — up to 100 users</td>
|
||||||
|
<td>per month</td>
|
||||||
|
<td class="num">£399.00</td>
|
||||||
|
<td>SSO & priority support included</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-004</td>
|
||||||
|
<td>Enterprise Plan — unlimited users</td>
|
||||||
|
<td>per month</td>
|
||||||
|
<td class="num">£899.00</td>
|
||||||
|
<td>Dedicated account manager</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-010</td>
|
||||||
|
<td>Annual licence discount (any plan)</td>
|
||||||
|
<td>per year</td>
|
||||||
|
<td class="num">−20%</td>
|
||||||
|
<td>Pay annually, save 20%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-020</td>
|
||||||
|
<td>Professional services — implementation</td>
|
||||||
|
<td>per day</td>
|
||||||
|
<td class="num">£1,200.00</td>
|
||||||
|
<td>Min. 1 day; remote or on-site</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-021</td>
|
||||||
|
<td>Training workshop (half-day, up to 10 delegates)</td>
|
||||||
|
<td>per session</td>
|
||||||
|
<td class="num">£650.00</td>
|
||||||
|
<td>Remote delivery only</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>VCS-030</td>
|
||||||
|
<td>Additional data storage</td>
|
||||||
|
<td>per 100 GB/month</td>
|
||||||
|
<td class="num">£18.00</td>
|
||||||
|
<td>First 50 GB included in plan</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">All prices are quoted in GBP and exclude VAT at the prevailing rate. Custom enterprise pricing is available for organisations requiring more than 500 users or bespoke SLA terms — please contact [email protected] for a tailored quote. Volume discounts of up to 15% are available for multi-year commitments.</div>
|
||||||
|
<div class="section-heading">Terms</div>
|
||||||
|
<div class="text-section">Subscriptions are invoiced monthly in advance unless an annual plan is selected. Payment is due within 14 days of invoice. Prices are subject to annual review; customers will be notified no less than 60 days before any change takes effect. All plans are subject to Vantage Cloud Services Standard Terms and Conditions (available at vantagecloud.co.uk/terms).</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{DOCUMENT_TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm 20mm 20mm 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Document wrapper ── */
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta info grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties grid ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card {
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-radius: 3pt;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card-header {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Addresses row ── */
|
||||||
|
.addresses-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Line items table ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table .desc-col {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Totals block ── */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
min-width: 140pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td {
|
||||||
|
padding: 2pt 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-top: 4pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notes / text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Authorization ── */
|
||||||
|
.auth-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
margin-top: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-table td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
width: 100pt;
|
||||||
|
padding: 2pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-table td:last-child {
|
||||||
|
padding: 2pt 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer note ── */
|
||||||
|
.footer-note {
|
||||||
|
margin-top: 10pt;
|
||||||
|
padding-top: 6pt;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.party-card { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{DOCUMENT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{BUYER_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">PO Number</div>
|
||||||
|
<div class="doc-number">{{PO_NUMBER}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">PO Date:</span> <span class="meta-value">{{PO_DATE}}</span>
|
||||||
|
<span class="meta-label">Buyer Reference:</span> <span class="meta-value">{{BUYER_REFERENCE}}</span>
|
||||||
|
<span class="meta-label">Vendor Reference:</span> <span class="meta-value">{{VENDOR_REFERENCE}}</span>
|
||||||
|
<span class="meta-label">Currency:</span> <span class="meta-value">{{CURRENCY}}</span>
|
||||||
|
<span class="meta-label">Requested Delivery:</span><span class="meta-value">{{REQUESTED_DELIVERY_DATE}}</span>
|
||||||
|
<span class="meta-label">Delivery Method:</span> <span class="meta-value">{{DELIVERY_METHOD}}</span>
|
||||||
|
<span class="meta-label">Incoterms:</span> <span class="meta-value">{{INCOTERMS}}</span>
|
||||||
|
<span class="meta-label">Payment Terms:</span> <span class="meta-value">{{PAYMENT_TERMS}}</span>
|
||||||
|
<span class="meta-label">Confidentiality:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties & Addresses</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Buyer</div>
|
||||||
|
<div class="party-name">{{BUYER_NAME}}</div>
|
||||||
|
<div class="party-detail">{{BUYER_ADDRESS}}</div>
|
||||||
|
<div class="party-detail">Contact: {{BUYER_CONTACT_NAME}}</div>
|
||||||
|
<div class="party-detail">Email: {{BUYER_CONTACT_EMAIL}}</div>
|
||||||
|
<div class="party-detail">Phone: {{BUYER_CONTACT_PHONE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Vendor</div>
|
||||||
|
<div class="party-name">{{VENDOR_NAME}}</div>
|
||||||
|
<div class="party-detail">{{VENDOR_ADDRESS}}</div>
|
||||||
|
<div class="party-detail">Contact: {{VENDOR_CONTACT_NAME}}</div>
|
||||||
|
<div class="party-detail">Email: {{VENDOR_CONTACT_EMAIL}}</div>
|
||||||
|
<div class="party-detail">Phone: {{VENDOR_CONTACT_PHONE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="addresses-row">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Bill To</div>
|
||||||
|
<div class="party-name">{{BILL_TO_NAME}}</div>
|
||||||
|
<div class="party-detail">{{BILL_TO_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Ship To</div>
|
||||||
|
<div class="party-name">{{SHIP_TO_NAME}}</div>
|
||||||
|
<div class="party-detail">{{SHIP_TO_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items -->
|
||||||
|
<div class="section-heading">Line Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:24pt">#</th>
|
||||||
|
<th class="desc-col">Description</th>
|
||||||
|
<th class="num" style="width:36pt">Qty</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{LINEITEM_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>{{SUBTOTAL_TEXT}}</td></tr>
|
||||||
|
<tr><td>Tax:</td><td>{{TAX_TEXT}}</td></tr>
|
||||||
|
<tr><td>Shipping:</td><td>{{SHIPPING_TEXT}}</td></tr>
|
||||||
|
<tr><td>Discount:</td><td>{{DISCOUNT_TEXT}}</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>{{TOTAL_TEXT}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">{{NOTES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Special Instructions -->
|
||||||
|
<div class="section-heading">Special Instructions</div>
|
||||||
|
<div class="text-section">{{SPECIAL_INSTRUCTIONS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Terms -->
|
||||||
|
<div class="section-heading">Terms and Conditions</div>
|
||||||
|
<div class="text-section">{{TERMS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Authorization -->
|
||||||
|
<div class="section-heading">Authorisation</div>
|
||||||
|
<table class="auth-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Authorised by:</td><td>{{AUTHORIZED_BY}}</td></tr>
|
||||||
|
<tr><td>Title:</td><td>{{AUTHORIZED_TITLE}}</td></tr>
|
||||||
|
<tr><td>Date:</td><td>{{AUTHORIZED_DATE}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer-note">{{FOOTER_NOTE}}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Purchase Order</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-bg);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.addresses-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-card { border: 0.5pt solid var(--theme-border); border-radius: 3pt; padding: 6pt 8pt; }
|
||||||
|
.party-card-header { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 4pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 10pt; }
|
||||||
|
.totals-table { border-collapse: collapse; font-size: 8.5pt; min-width: 140pt; }
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||||
|
.totals-table td:last-child { text-align: right; }
|
||||||
|
.totals-table .total-row td { font-weight: 700; font-size: 9.5pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; }
|
||||||
|
.text-section { font-size: 8.5pt; line-height: 1.5; margin-bottom: 4pt; }
|
||||||
|
.auth-table { width: 100%; border-collapse: collapse; font-size: 8.5pt; margin-top: 4pt; }
|
||||||
|
.auth-table td:first-child { font-weight: 600; color: var(--theme-text-muted); width: 100pt; padding: 2pt 0; }
|
||||||
|
.auth-table td:last-child { padding: 2pt 6pt; }
|
||||||
|
.footer-note { margin-top: 10pt; padding-top: 6pt; border-top: 0.5pt solid var(--theme-border); font-size: 7.5pt; color: var(--theme-text-muted); }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Purchase Order</div>
|
||||||
|
<div class="doc-subtitle">Sterling Systems Group Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">PO Number</div>
|
||||||
|
<div class="doc-number">PO-2026-0184</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">PO Date:</span> <span class="meta-value">12 January 2026</span>
|
||||||
|
<span class="meta-label">Buyer Reference:</span> <span class="meta-value">REQ-A19-772</span>
|
||||||
|
<span class="meta-label">Vendor Reference:</span> <span class="meta-value">QUOTE-Q4481</span>
|
||||||
|
<span class="meta-label">Currency:</span> <span class="meta-value">GBP</span>
|
||||||
|
<span class="meta-label">Requested Delivery:</span><span class="meta-value">19 January 2026</span>
|
||||||
|
<span class="meta-label">Delivery Method:</span> <span class="meta-value">Courier</span>
|
||||||
|
<span class="meta-label">Incoterms:</span> <span class="meta-value">DAP (Delivered at Place)</span>
|
||||||
|
<span class="meta-label">Payment Terms:</span> <span class="meta-value">Net 30</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Parties & Addresses</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Buyer</div>
|
||||||
|
<div class="party-name">Sterling Systems Group Ltd</div>
|
||||||
|
<div class="party-detail">100 Kingfisher Way, London, EC2V 7AB, UK</div>
|
||||||
|
<div class="party-detail">Contact: Operations Procurement</div>
|
||||||
|
<div class="party-detail">Email: [email protected]</div>
|
||||||
|
<div class="party-detail">Phone: +44 20 0000 0000</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Vendor</div>
|
||||||
|
<div class="party-name">Northbridge Office Supply Co.</div>
|
||||||
|
<div class="party-detail">14 Meridian Park, Reading, RG1 1AA, UK</div>
|
||||||
|
<div class="party-detail">Contact: Accounts Desk</div>
|
||||||
|
<div class="party-detail">Email: [email protected]</div>
|
||||||
|
<div class="party-detail">Phone: +44 118 000 0000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="addresses-row">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Bill To</div>
|
||||||
|
<div class="party-name">Sterling Systems Group Ltd (Accounts Payable)</div>
|
||||||
|
<div class="party-detail">100 Kingfisher Way, London, EC2V 7AB, UK</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Ship To</div>
|
||||||
|
<div class="party-name">Sterling Systems Group Ltd (Receiving)</div>
|
||||||
|
<div class="party-detail">Dock 2, 100 Kingfisher Way, London, EC2V 7AB, UK</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Line Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:24pt">#</th>
|
||||||
|
<th style="width:45%">Description</th>
|
||||||
|
<th class="num" style="width:36pt">Qty</th>
|
||||||
|
<th style="width:36pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>A4 paper, 80gsm (box of 5 reams)</td>
|
||||||
|
<td class="num">20</td>
|
||||||
|
<td>box</td>
|
||||||
|
<td class="num">£18.50</td>
|
||||||
|
<td class="num">£370.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Black toner cartridge (Model X200)</td>
|
||||||
|
<td class="num">6</td>
|
||||||
|
<td>unit</td>
|
||||||
|
<td class="num">£74.00</td>
|
||||||
|
<td class="num">£444.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Heavy-duty archive boxes (pack of 10)</td>
|
||||||
|
<td class="num">8</td>
|
||||||
|
<td>pack</td>
|
||||||
|
<td class="num">£21.25</td>
|
||||||
|
<td class="num">£170.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>£984.00</td></tr>
|
||||||
|
<tr><td>Tax (20% VAT):</td><td>£196.80</td></tr>
|
||||||
|
<tr><td>Shipping:</td><td>£25.00</td></tr>
|
||||||
|
<tr><td>Discount:</td><td>-£30.00</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>£1,175.80</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">Deliveries accepted Mon–Fri, 09:00–16:00. Please reference the PO number on all delivery documents.</div>
|
||||||
|
<div class="section-heading">Special Instructions</div>
|
||||||
|
<div class="text-section">Pack cartons securely; label each carton with PO number and delivery contact.</div>
|
||||||
|
<div class="section-heading">Terms and Conditions</div>
|
||||||
|
<div class="text-section">Payment per stated terms; substitutions require buyer approval; notify buyer of delays exceeding 48 hours.</div>
|
||||||
|
<div class="section-heading">Authorisation</div>
|
||||||
|
<table class="auth-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Authorised by:</td><td>Alexandra Moore</td></tr>
|
||||||
|
<tr><td>Title:</td><td>Head of Operations</td></tr>
|
||||||
|
<tr><td>Date:</td><td>12 January 2026</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer-note">This document is provided for demonstration purposes only.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quotation {{QUOTE_NUMBER}}</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta info grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value.valid-until {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Party cards ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card {
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-radius: 3pt;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card-header {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Line items table ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table .desc-col { width: 40%; }
|
||||||
|
|
||||||
|
/* ── Totals block ── */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
min-width: 150pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-top: 4pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .grand-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11pt;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Acceptance box ── */
|
||||||
|
.acceptance-box {
|
||||||
|
border: 0.75pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-radius: 3pt;
|
||||||
|
padding: 8pt 10pt;
|
||||||
|
margin-top: 10pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acceptance-title {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acceptance-text {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acceptance-sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-sig-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.party-card { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.acceptance-box { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
.acceptance-box { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Quotation</div>
|
||||||
|
<div class="doc-subtitle">{{VENDOR_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Quote Number</div>
|
||||||
|
<div class="doc-number">{{QUOTE_NUMBER}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Quote Date:</span> <span class="meta-value">{{QUOTE_DATE}}</span>
|
||||||
|
<span class="meta-label">Valid Until:</span> <span class="meta-value valid-until">{{VALID_UNTIL}}</span>
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">{{REFERENCE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">From</div>
|
||||||
|
<div class="party-name">{{VENDOR_NAME}}</div>
|
||||||
|
<div class="party-detail">{{VENDOR_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Quote To</div>
|
||||||
|
<div class="party-name">{{CLIENT_NAME}}</div>
|
||||||
|
<div class="party-detail">{{CLIENT_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items -->
|
||||||
|
<div class="section-heading">Line Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22pt">#</th>
|
||||||
|
<th class="desc-col">Description</th>
|
||||||
|
<th class="num" style="width:32pt">Qty</th>
|
||||||
|
<th style="width:32pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{LINEITEM_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>{{SUBTOTAL_TEXT}}</td></tr>
|
||||||
|
<tr><td>Discount:</td><td>{{DISCOUNT_TEXT}}</td></tr>
|
||||||
|
<tr><td>Tax:</td><td>{{TAX_TEXT}}</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>{{TOTAL_TEXT}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terms -->
|
||||||
|
<div class="section-heading">Terms & Conditions</div>
|
||||||
|
<div class="text-section">{{TERMS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">{{NOTES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Acceptance -->
|
||||||
|
<div class="acceptance-box">
|
||||||
|
<div class="acceptance-title">Acceptance</div>
|
||||||
|
<div class="acceptance-text">By signing below, the client accepts this quotation and authorises the work described herein. This quotation is valid until {{ACCEPTANCE_DEADLINE}}.</div>
|
||||||
|
<div class="acceptance-sig-grid">
|
||||||
|
<div>
|
||||||
|
<div class="acc-sig-line"></div>
|
||||||
|
<div class="acc-sig-label">Authorised Signature</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="acc-sig-line"></div>
|
||||||
|
<div class="acc-sig-label">Date</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quotation</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.meta-value.valid-until { font-weight: 600; color: var(--theme-accent); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-card { border: 0.5pt solid var(--theme-border); border-radius: 3pt; padding: 6pt 8pt; }
|
||||||
|
.party-card-header { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 4pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 10pt; }
|
||||||
|
.totals-table { border-collapse: collapse; font-size: 8.5pt; min-width: 150pt; }
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||||
|
.totals-table td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.totals-table .total-row td { font-weight: 700; font-size: 10pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; color: var(--theme-heading); }
|
||||||
|
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.acceptance-box { border: 0.75pt solid var(--theme-border); border-radius: 3pt; padding: 8pt 10pt; margin-top: 10pt; page-break-inside: avoid; }
|
||||||
|
.acceptance-title { font-size: 9pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6pt; }
|
||||||
|
.acceptance-text { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 12pt; }
|
||||||
|
.acceptance-sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16pt; }
|
||||||
|
.acc-sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.acc-sig-label { font-size: 7.5pt; color: var(--theme-text-muted); }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .acceptance-box { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Quotation</div>
|
||||||
|
<div class="doc-subtitle">Axiom Technology Solutions Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Quote Number</div>
|
||||||
|
<div class="doc-number">QT-2026-0089</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Quote Date:</span> <span class="meta-value">19 February 2026</span>
|
||||||
|
<span class="meta-label">Valid Until:</span> <span class="meta-value valid-until">19 March 2026</span>
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">BLP-IT-2026-Q3</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">From</div>
|
||||||
|
<div class="party-name">Axiom Technology Solutions Ltd</div>
|
||||||
|
<div class="party-detail">9 Silicon Way, Reading, RG1 1EH, UK</div>
|
||||||
|
<div class="party-detail">VAT: GB456789012 · [email protected]</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Quote To</div>
|
||||||
|
<div class="party-name">Brookfield Legal Partners LLP</div>
|
||||||
|
<div class="party-detail">55 Temple Row, Birmingham, B2 5LU, UK</div>
|
||||||
|
<div class="party-detail">Attn: Marcus Hale, IT Manager</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Line Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22pt">#</th>
|
||||||
|
<th style="width:40%">Description</th>
|
||||||
|
<th class="num" style="width:32pt">Qty</th>
|
||||||
|
<th style="width:32pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>Dell OptiPlex 7020 Desktop (i7, 32 GB RAM, 512 GB SSD)</td>
|
||||||
|
<td class="num">15</td>
|
||||||
|
<td>unit</td>
|
||||||
|
<td class="num">£849.00</td>
|
||||||
|
<td class="num">£12,735.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Dell 27" UltraSharp Monitor U2724D</td>
|
||||||
|
<td class="num">15</td>
|
||||||
|
<td>unit</td>
|
||||||
|
<td class="num">£389.00</td>
|
||||||
|
<td class="num">£5,835.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Microsoft 365 Business Premium (annual subscription)</td>
|
||||||
|
<td class="num">20</td>
|
||||||
|
<td>licence</td>
|
||||||
|
<td class="num">£220.00</td>
|
||||||
|
<td class="num">£4,400.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>4</td>
|
||||||
|
<td>On-site setup, imaging, and deployment (per device)</td>
|
||||||
|
<td class="num">15</td>
|
||||||
|
<td>device</td>
|
||||||
|
<td class="num">£75.00</td>
|
||||||
|
<td class="num">£1,125.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>5</td>
|
||||||
|
<td>3-year on-site warranty extension (Tier 2)</td>
|
||||||
|
<td class="num">15</td>
|
||||||
|
<td>unit</td>
|
||||||
|
<td class="num">£110.00</td>
|
||||||
|
<td class="num">£1,650.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>£25,745.00</td></tr>
|
||||||
|
<tr><td>Discount (5% volume):</td><td>−£1,287.25</td></tr>
|
||||||
|
<tr><td>VAT (20%):</td><td>£4,891.55</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>£29,349.30</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Terms & Conditions</div>
|
||||||
|
<div class="text-section">Payment is due within 30 days of invoice date. Hardware items ship within 5–7 business days of order confirmation. Software licences are delivered electronically within 24 hours. All hardware carries a minimum 12-month manufacturer warranty. Returns accepted within 14 days of delivery for unopened items only.</div>
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">Prices are inclusive of delivery to a single UK mainland address. Additional delivery locations are available at £35 per site. Volume pricing shown reflects a 5% discount applied to the hardware lines only. Quote is valid for 28 days from the date shown above. Please quote reference BLP-IT-2026-Q3 on all correspondence.</div>
|
||||||
|
<div class="acceptance-box">
|
||||||
|
<div class="acceptance-title">Acceptance</div>
|
||||||
|
<div class="acceptance-text">By signing below, the client accepts this quotation and authorises the work described herein. This quotation is valid until 19 March 2026.</div>
|
||||||
|
<div class="acceptance-sig-grid">
|
||||||
|
<div>
|
||||||
|
<div class="acc-sig-line"></div>
|
||||||
|
<div class="acc-sig-label">Authorised Signature</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="acc-sig-line"></div>
|
||||||
|
<div class="acc-sig-label">Date</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Receipt {{RECEIPT_NUMBER}}</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top accent bar ── */
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta info grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Party cards ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card {
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-radius: 3pt;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-card-header {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Line items table ── */
|
||||||
|
.items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th {
|
||||||
|
padding: 5pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
|
||||||
|
.items-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table tbody td.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-table .desc-col { width: 40%; }
|
||||||
|
|
||||||
|
/* ── Totals block ── */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
min-width: 150pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
|
||||||
|
.totals-table td:first-child {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .total-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-top: 4pt;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-table .received-row td {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Payment details grid ── */
|
||||||
|
.payment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 3pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Issued by block ── */
|
||||||
|
.issued-by-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 3pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.party-card { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Receipt</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Receipt Number</div>
|
||||||
|
<div class="doc-number">{{RECEIPT_NUMBER}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Receipt Date:</span> <span class="meta-value">{{RECEIPT_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Issued By</div>
|
||||||
|
<div class="party-name">{{ISSUER_NAME}}</div>
|
||||||
|
<div class="party-detail">{{ISSUER_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Received From</div>
|
||||||
|
<div class="party-name">{{RECEIVED_FROM_NAME}}</div>
|
||||||
|
<div class="party-detail">{{RECEIVED_FROM_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line Items -->
|
||||||
|
<div class="section-heading">Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22pt">#</th>
|
||||||
|
<th class="desc-col">Description</th>
|
||||||
|
<th class="num" style="width:32pt">Qty</th>
|
||||||
|
<th style="width:32pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{LINEITEM_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>{{SUBTOTAL_TEXT}}</td></tr>
|
||||||
|
<tr><td>Tax:</td><td>{{TAX_TEXT}}</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>{{TOTAL_TEXT}}</td></tr>
|
||||||
|
<tr class="received-row"><td>Amount Received:</td><td>{{AMOUNT_RECEIVED_TEXT}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Details -->
|
||||||
|
<div class="section-heading">Payment Details</div>
|
||||||
|
<div class="payment-grid">
|
||||||
|
<span class="payment-label">Payment Method:</span> <span class="payment-value">{{PAYMENT_METHOD_TEXT}}</span>
|
||||||
|
<span class="payment-label">Payment Reference:</span> <span class="payment-value">{{PAYMENT_REFERENCE_TEXT}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">{{NOTES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Issued By -->
|
||||||
|
<div class="section-heading">Issued By</div>
|
||||||
|
<div class="issued-by-grid">
|
||||||
|
<span class="meta-label">Name:</span> <span class="meta-value">{{ISSUED_BY_NAME}}</span>
|
||||||
|
<span class="meta-label">Title:</span> <span class="meta-value">{{ISSUED_BY_TITLE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Receipt</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.doc-number-area { text-align: right; flex-shrink: 0; }
|
||||||
|
.doc-number-label { font-size: 7.5pt; color: var(--theme-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2pt; }
|
||||||
|
.doc-number { font-size: 11pt; font-weight: 700; color: var(--theme-primary); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-card { border: 0.5pt solid var(--theme-border); border-radius: 3pt; padding: 6pt 8pt; }
|
||||||
|
.party-card-header { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 4pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||||
|
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||||
|
.items-table thead th.num { text-align: right; }
|
||||||
|
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||||
|
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 10pt; }
|
||||||
|
.totals-table { border-collapse: collapse; font-size: 8.5pt; min-width: 150pt; }
|
||||||
|
.totals-table td { padding: 2pt 6pt; }
|
||||||
|
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||||
|
.totals-table td:last-child { text-align: right; }
|
||||||
|
.totals-table .total-row td { font-weight: 700; font-size: 10pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; color: var(--theme-heading); }
|
||||||
|
.totals-table .received-row td { font-weight: 700; font-size: 10pt; color: var(--theme-accent); }
|
||||||
|
.payment-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 3pt; margin-bottom: 8pt; }
|
||||||
|
.payment-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.payment-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.issued-by-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 3pt; margin-bottom: 8pt; }
|
||||||
|
.text-section { font-size: 8.5pt; line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Receipt</div>
|
||||||
|
<div class="doc-subtitle">Blackthorn Digital Ltd</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-number-area">
|
||||||
|
<div class="doc-number-label">Receipt Number</div>
|
||||||
|
<div class="doc-number">REC-2026-0083</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Receipt Date:</span> <span class="meta-value">19 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Issued By</div>
|
||||||
|
<div class="party-name">Blackthorn Digital Ltd</div>
|
||||||
|
<div class="party-detail">14 Farringdon Street, London, EC4A 4HH, UK</div>
|
||||||
|
<div class="party-detail">VAT No: GB987654321</div>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<div class="party-card-header">Received From</div>
|
||||||
|
<div class="party-name">Meridian Solutions plc</div>
|
||||||
|
<div class="party-detail">30 King Street, Birmingham, B1 2LT, UK</div>
|
||||||
|
<div class="party-detail">Accounts Payable</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Items</div>
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22pt">#</th>
|
||||||
|
<th style="width:40%">Description</th>
|
||||||
|
<th class="num" style="width:32pt">Qty</th>
|
||||||
|
<th style="width:32pt">Unit</th>
|
||||||
|
<th class="num" style="width:55pt">Unit Price</th>
|
||||||
|
<th class="num" style="width:55pt">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>Software licence — Professional tier (annual)</td>
|
||||||
|
<td class="num">5</td>
|
||||||
|
<td>seat</td>
|
||||||
|
<td class="num">£480.00</td>
|
||||||
|
<td class="num">£2,400.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Onboarding & implementation support</td>
|
||||||
|
<td class="num">8</td>
|
||||||
|
<td>hour</td>
|
||||||
|
<td class="num">£150.00</td>
|
||||||
|
<td class="num">£1,200.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Priority support package (12 months)</td>
|
||||||
|
<td class="num">1</td>
|
||||||
|
<td>package</td>
|
||||||
|
<td class="num">£600.00</td>
|
||||||
|
<td class="num">£600.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Subtotal:</td><td>£4,200.00</td></tr>
|
||||||
|
<tr><td>VAT (20%):</td><td>£840.00</td></tr>
|
||||||
|
<tr class="total-row"><td>Total:</td><td>£5,040.00</td></tr>
|
||||||
|
<tr class="received-row"><td>Amount Received:</td><td>£5,040.00</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Payment Details</div>
|
||||||
|
<div class="payment-grid">
|
||||||
|
<span class="payment-label">Payment Method:</span> <span class="payment-value">BACS Bank Transfer</span>
|
||||||
|
<span class="payment-label">Payment Reference:</span> <span class="payment-value">REC-2026-0083 / PO-MER-4421</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Notes</div>
|
||||||
|
<div class="text-section">This receipt confirms full payment received for the above services. Please retain for your records. A duplicate copy has been sent to [email protected].</div>
|
||||||
|
<div class="section-heading">Issued By</div>
|
||||||
|
<div class="issued-by-grid">
|
||||||
|
<span class="meta-label">Name:</span> <span class="meta-value">Oliver Pemberton</span>
|
||||||
|
<span class="meta-label">Title:</span> <span class="meta-value">Finance Manager</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{FULL_NAME}} — Resume</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm 18mm 15mm 18mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 9.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 174mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header / Name block ── */
|
||||||
|
.resume-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12pt;
|
||||||
|
border-bottom: 2pt solid var(--theme-primary, #1e3a5f);
|
||||||
|
padding-bottom: 8pt;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-header .company-logo { flex-shrink: 0; margin-top: 2pt; }
|
||||||
|
|
||||||
|
.resume-header-text { flex: 1; }
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 22pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-top: 2pt;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12pt;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: 0.75pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Summary ── */
|
||||||
|
.summary-text {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Experience / Education entries ── */
|
||||||
|
.entry {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 1pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-title {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-dates {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-org {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-location {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-bullets {
|
||||||
|
margin-top: 3pt;
|
||||||
|
padding-left: 12pt;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-bullets li {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
margin-bottom: 1.5pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-description {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skills ── */
|
||||||
|
.skills-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 4pt 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category {
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category-name {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-list {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Certifications / Projects ── */
|
||||||
|
.cert-item {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-issuer {
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.entry { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.skill-category { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.cert-item { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
.entry { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="resume-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="resume-header-text">
|
||||||
|
<div class="name">{{FULL_NAME}}</div>
|
||||||
|
<div class="job-title">{{JOB_TITLE}}</div>
|
||||||
|
<div class="contact-row">
|
||||||
|
<span class="contact-item">{{EMAIL}}</span>
|
||||||
|
<span class="contact-item">{{PHONE}}</span>
|
||||||
|
<span class="contact-item">{{LOCATION}}</span>
|
||||||
|
<span class="contact-item">{{LINKEDIN}}</span>
|
||||||
|
<span class="contact-item">{{WEBSITE}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Professional Summary</div>
|
||||||
|
<div class="summary-text">{{SUMMARY}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Experience -->
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Work Experience</div>
|
||||||
|
{{EXPERIENCE_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Education -->
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Education</div>
|
||||||
|
{{EDUCATION_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Skills -->
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Skills</div>
|
||||||
|
<div class="skills-grid">
|
||||||
|
{{SKILLS_ITEMS}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Certifications (optional) -->
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Certifications</div>
|
||||||
|
{{CERTIFICATIONS_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Projects (optional) -->
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Projects</div>
|
||||||
|
{{PROJECTS_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Alexandra Chen — Resume</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
@page { size: A4; margin: 15mm 18mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 9.5pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.45; }
|
||||||
|
.document { max-width: 174mm; margin: 0 auto; }
|
||||||
|
.resume-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12pt;
|
||||||
|
border-bottom: 2pt solid var(--theme-primary);
|
||||||
|
padding-bottom: 8pt;
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
.resume-header-text { flex: 1; }
|
||||||
|
.name { font-size: 22pt; font-weight: 700; color: var(--theme-heading); letter-spacing: -0.5px; line-height: 1.1; }
|
||||||
|
.job-title { font-size: 11pt; font-weight: 400; color: var(--theme-accent); margin-top: 2pt; margin-bottom: 6pt; }
|
||||||
|
.contact-row { display: flex; flex-wrap: wrap; gap: 12pt; font-size: 8.5pt; color: var(--theme-text-muted); }
|
||||||
|
.section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 1px; border-bottom: 0.75pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 5pt; }
|
||||||
|
.summary-text { font-size: 9pt; line-height: 1.5; }
|
||||||
|
.entry { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.entry-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1pt; }
|
||||||
|
.entry-title { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); }
|
||||||
|
.entry-dates { font-size: 8pt; color: var(--theme-text-muted); white-space: nowrap; }
|
||||||
|
.entry-org { font-size: 9pt; font-weight: 600; color: var(--theme-accent); margin-bottom: 2pt; }
|
||||||
|
.entry-bullets { margin-top: 3pt; padding-left: 12pt; list-style: disc; }
|
||||||
|
.entry-bullets li { font-size: 8.5pt; margin-bottom: 1.5pt; line-height: 1.4; }
|
||||||
|
.skills-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4pt 10pt; }
|
||||||
|
.skill-category-name { font-size: 8.5pt; font-weight: 700; color: var(--theme-primary); margin-bottom: 2pt; }
|
||||||
|
.skill-list { font-size: 8pt; line-height: 1.5; }
|
||||||
|
.cert-item { font-size: 8.5pt; margin-bottom: 3pt; }
|
||||||
|
.cert-name { font-weight: 600; }
|
||||||
|
.cert-issuer { color: var(--theme-text-muted); font-size: 8pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<header class="resume-header">
|
||||||
|
<div class="resume-header-text">
|
||||||
|
<div class="name">Alexandra Chen</div>
|
||||||
|
<div class="job-title">Senior Software Engineer</div>
|
||||||
|
<div class="contact-row">
|
||||||
|
<span>[email protected]</span>
|
||||||
|
<span>+1 (555) 234-5678</span>
|
||||||
|
<span>San Francisco, CA</span>
|
||||||
|
<span>linkedin.com/in/alexchen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Professional Summary</div>
|
||||||
|
<div class="summary-text">Results-driven Senior Software Engineer with 8+ years of experience designing and building scalable distributed systems. Proven track record of leading cross-functional teams, reducing infrastructure costs by 40%, and shipping products used by millions of users. Passionate about developer experience, performance optimisation, and mentoring junior engineers.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Work Experience</div>
|
||||||
|
|
||||||
|
<div class="entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-title">Senior Software Engineer</span>
|
||||||
|
<span class="entry-dates">Mar 2021 – Present</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-org">TechCorp Inc. — San Francisco, CA</div>
|
||||||
|
<ul class="entry-bullets">
|
||||||
|
<li>Led architecture redesign of core payments service, reducing p99 latency from 800ms to 120ms.</li>
|
||||||
|
<li>Managed a team of 6 engineers across 3 time zones, shipping 2 major product releases per year.</li>
|
||||||
|
<li>Reduced cloud infrastructure spend by 40% through rightsizing and caching strategies.</li>
|
||||||
|
<li>Established engineering standards and code review practices adopted company-wide.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-title">Software Engineer II</span>
|
||||||
|
<span class="entry-dates">Jun 2018 – Mar 2021</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-org">StartupXYZ — New York, NY</div>
|
||||||
|
<ul class="entry-bullets">
|
||||||
|
<li>Built real-time data pipeline processing 50M events/day using Kafka and Apache Flink.</li>
|
||||||
|
<li>Designed and launched REST API serving 5M daily active users with 99.95% uptime.</li>
|
||||||
|
<li>Reduced CI/CD pipeline duration from 45 min to 8 min through parallelisation.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-title">Software Engineer</span>
|
||||||
|
<span class="entry-dates">Aug 2016 – Jun 2018</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-org">DataSolutions Ltd — Boston, MA</div>
|
||||||
|
<ul class="entry-bullets">
|
||||||
|
<li>Developed ETL pipelines for Fortune 500 clients, processing terabytes of data nightly.</li>
|
||||||
|
<li>Built internal tooling that saved the analytics team 10 hours per week.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Education</div>
|
||||||
|
<div class="entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-title">B.Sc. Computer Science</span>
|
||||||
|
<span class="entry-dates">2012 – 2016</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-org">Massachusetts Institute of Technology (MIT)</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Skills</div>
|
||||||
|
<div class="skills-grid">
|
||||||
|
<div>
|
||||||
|
<div class="skill-category-name">Languages</div>
|
||||||
|
<div class="skill-list">Python, Go, TypeScript, Java, SQL</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="skill-category-name">Infrastructure</div>
|
||||||
|
<div class="skill-list">AWS, GCP, Kubernetes, Terraform, Docker</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="skill-category-name">Frameworks & Tools</div>
|
||||||
|
<div class="skill-list">React, Node.js, FastAPI, Kafka, Redis, PostgreSQL</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">Certifications</div>
|
||||||
|
<div class="cert-item">
|
||||||
|
<span class="cert-name">AWS Certified Solutions Architect – Professional</span>
|
||||||
|
<span class="cert-issuer"> · Amazon Web Services · 2023</span>
|
||||||
|
</div>
|
||||||
|
<div class="cert-item">
|
||||||
|
<span class="cert-name">Google Cloud Professional Data Engineer</span>
|
||||||
|
<span class="cert-issuer"> · Google · 2022</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SAFE Agreement</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key financial terms highlight box ── */
|
||||||
|
.terms-box {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-left: 3pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding: 8pt 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-box-title {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120pt auto;
|
||||||
|
row-gap: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-kv-label {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-kv-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.terms-box { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.terms-kv { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{AGREEMENT_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">Simple Agreement for Future Equity</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">{{AGREEMENT_REF}}</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">{{GOVERNING_LAW}}</span>
|
||||||
|
<span class="meta-label">Classification:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Company</div>
|
||||||
|
<div class="party-name">{{COMPANY_LEGAL_NAME}}</div>
|
||||||
|
<div class="party-detail">{{COMPANY_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Investor</div>
|
||||||
|
<div class="party-name">{{INVESTOR_LEGAL_NAME}}</div>
|
||||||
|
<div class="party-detail">{{INVESTOR_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key financial terms -->
|
||||||
|
<div class="terms-box">
|
||||||
|
<div class="terms-box-title">Key Financial Terms</div>
|
||||||
|
<div class="terms-kv">
|
||||||
|
<span class="terms-kv-label">Purchase Amount:</span> <span class="terms-kv-value">{{PURCHASE_AMOUNT}}</span>
|
||||||
|
<span class="terms-kv-label">Valuation Cap:</span> <span class="terms-kv-value">{{VALUATION_CAP}}</span>
|
||||||
|
<span class="terms-kv-label">Discount Rate:</span> <span class="terms-kv-value">{{DISCOUNT_RATE}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal sections -->
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Purchase Amount</div>
|
||||||
|
<div class="legal-section-body">In consideration of the rights described in this Agreement, the Investor agrees to invest {{PURCHASE_AMOUNT}} in the Company (the "Purchase Amount"). The Company shall use the Purchase Amount for general corporate and operating purposes.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Valuation Cap</div>
|
||||||
|
<div class="legal-section-body">This SAFE is subject to a valuation cap of {{VALUATION_CAP}} (the "Valuation Cap"). The Valuation Cap is used to determine the number of shares issued to the Investor upon conversion of this SAFE.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Discount Rate</div>
|
||||||
|
<div class="legal-section-body">This SAFE includes a discount rate of {{DISCOUNT_RATE}} applicable upon conversion. This discount shall be applied to the price per share paid by investors in the relevant equity financing round, and the Investor shall receive shares at the lower of the discounted price or the Valuation Cap price.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Most Favoured Nation</div>
|
||||||
|
<div class="legal-section-body">{{MFN_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Conversion Events</div>
|
||||||
|
<div class="legal-section-body">{{CONVERSION_EVENTS_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Termination</div>
|
||||||
|
<div class="legal-section-body">{{TERMINATION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Governing Law</div>
|
||||||
|
<div class="legal-section-body">This Agreement shall be governed by and construed in accordance with the laws of {{GOVERNING_LAW}}.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have executed this Agreement as of the date first written above.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Company</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: {{COMPANY_SIGNATORY}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Investor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: {{INVESTOR_SIGNATORY}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SAFE Agreement — Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { text-align: center; margin-bottom: 8pt; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.terms-box { background: var(--theme-surface); border: 0.5pt solid var(--theme-border); border-left: 3pt solid var(--theme-accent); padding: 8pt 10pt; margin-bottom: 8pt; }
|
||||||
|
.terms-box-title { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 6pt; }
|
||||||
|
.terms-kv { display: grid; grid-template-columns: 120pt auto; row-gap: 3pt; }
|
||||||
|
.terms-kv-label { font-size: 8.5pt; font-weight: 600; color: var(--theme-text-muted); }
|
||||||
|
.terms-kv-value { font-size: 8.5pt; font-weight: 700; color: var(--theme-heading); }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-title">SAFE Agreement</div>
|
||||||
|
<div class="doc-subtitle">Simple Agreement for Future Equity</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Reference:</span> <span class="meta-value">SAFE-2026-003</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">15 February 2026</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">England and Wales</span>
|
||||||
|
<span class="meta-label">Classification:</span> <span class="meta-value">Confidential</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Company</div>
|
||||||
|
<div class="party-name">Helix Biotech Ltd</div>
|
||||||
|
<div class="party-detail">72 Science Park Road, Cambridge, CB4 0DW, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Investor</div>
|
||||||
|
<div class="party-name">Beacon Ventures Fund II LP</div>
|
||||||
|
<div class="party-detail">8 St James's Square, London, SW1Y 4JU, United Kingdom</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terms-box">
|
||||||
|
<div class="terms-box-title">Key Financial Terms</div>
|
||||||
|
<div class="terms-kv">
|
||||||
|
<span class="terms-kv-label">Purchase Amount:</span> <span class="terms-kv-value">£500,000</span>
|
||||||
|
<span class="terms-kv-label">Valuation Cap:</span> <span class="terms-kv-value">£8,000,000 post-money</span>
|
||||||
|
<span class="terms-kv-label">Discount Rate:</span> <span class="terms-kv-value">20%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Purchase Amount</div>
|
||||||
|
<div class="legal-section-body">In consideration of the rights described in this Agreement, the Investor agrees to invest £500,000 in the Company (the "Purchase Amount"). The Company shall use the Purchase Amount for general corporate and operating purposes, including research and development activities.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Valuation Cap</div>
|
||||||
|
<div class="legal-section-body">This SAFE is subject to a valuation cap of £8,000,000 post-money (the "Valuation Cap"). The Valuation Cap is used to determine the number of shares issued to the Investor upon conversion of this SAFE. The Investor will receive shares based on whichever calculation results in a lower price per share: the Valuation Cap price or the discounted price.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Discount Rate</div>
|
||||||
|
<div class="legal-section-body">This SAFE includes a discount rate of 20% applicable upon conversion. This discount shall be applied to the price per share paid by new investors in the relevant equity financing round. The Investor shall receive shares at the lower of: (a) the price per share multiplied by 80%; or (b) the price per share calculated using the Valuation Cap.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Most Favoured Nation</div>
|
||||||
|
<div class="legal-section-body">If the Company issues any SAFEs or convertible instruments to future investors on terms more favourable than those set out herein (including a lower valuation cap or higher discount rate), the Company shall promptly notify the Investor and the Investor shall have the right, within thirty (30) days of such notification, to elect to have this SAFE amended to incorporate such more favourable terms.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Conversion Events</div>
|
||||||
|
<div class="legal-section-body">This SAFE will automatically convert into shares of the Company's preferred stock (or equivalent) upon the occurrence of any of the following events ("Conversion Events"):
|
||||||
|
|
||||||
|
(a) Equity Financing: An equity financing in which the Company sells preferred shares for aggregate gross proceeds of at least £2,000,000, at which time this SAFE converts into shares of the same class at the conversion price determined under Sections 2 and 3 above.
|
||||||
|
|
||||||
|
(b) Liquidity Event: A change of control, sale, merger, or similar transaction, at which time the Investor shall receive, at the Investor's election, either: (i) a cash payment equal to the Purchase Amount; or (ii) shares of the Company immediately prior to the liquidity event at the conversion price.
|
||||||
|
|
||||||
|
(c) Dissolution Event: In the event of a winding-up or dissolution of the Company, the Investor shall be entitled to receive, prior to any distribution to ordinary shareholders, an amount equal to the Purchase Amount, subject to the rights of any senior creditors.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Termination</div>
|
||||||
|
<div class="legal-section-body">This SAFE will terminate (without any payment to the Investor) upon: (a) conversion in full pursuant to Section 5; or (b) payment in full of the Purchase Amount in the case of a dissolution event or liquidity event as described in Section 5. No party may unilaterally terminate this SAFE prior to a Conversion Event.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Governing Law</div>
|
||||||
|
<div class="legal-section-body">This Agreement shall be governed by and construed in accordance with the laws of England and Wales. The parties irrevocably submit to the exclusive jurisdiction of the courts of England and Wales to resolve any dispute arising under or in connection with this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have executed this Agreement as of the date first written above.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Company</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: Dr. Amelia Hargreaves, CEO</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 15 February 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">For the Investor</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name / Title: Thomas Beacon, General Partner</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 15 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Sample Prompts
|
||||||
|
|
||||||
|
One sample prompt per document template. Use these to test generation or as inspiration for users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## advisor_agreement
|
||||||
|
Create an advisor agreement between Helix Biotech Ltd (Company Registration Number 09876543), whose registered office is at 42 Innovation Way, Cambridge CB2 8AB, and Dr. Priya Sharma of 15 Oak Lane, London NW3 2PT, a clinical research advisor. The agreement is effective from 1 April 2026. Dr. Sharma will advise on regulatory strategy (including MHRA and EMA submissions), clinical trial design, and protocol development for the Company's lead oncology programme (Project代号 HELIX-ONC-001) for an initial term of 18 months, extendable by mutual written agreement. She commits to a minimum of 4 hours per week, with availability for up to 2 board or advisory committee meetings per quarter. Compensation: 0.3% equity options (fully diluted) vesting monthly over 24 months with a 6-month cliff from the effective date, plus a monthly retainer of £500 payable in arrears by the 15th of each month. She will receive reimbursement of reasonable out-of-pocket expenses incurred with prior approval. She confirms she has no conflicting commitments and will maintain confidentiality of all Company information. The agreement includes standard indemnification for advice given in good faith, and termination on 30 days' written notice by either party, with any unvested options lapsing on termination. Intellectual property arising from the advisory work is assigned to the Company. The agreement is governed by English law and subject to the exclusive jurisdiction of the courts of England and Wales. Include signature blocks for both parties and the date of execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## audit_report
|
||||||
|
Create an internal audit report for Vantage Capital Partners Ltd. Audit reference IA-2026-11. The audit was conducted over the period 15 January to 31 March 2026 and covered payroll processing and HR data controls across the firm's London and Edinburgh offices. The audit was performed by the internal audit function; lead auditor James Hartley (CIA, CISA), with fieldwork support from Emma Chen and Oliver Wright. The scope included: payroll authorisation and segregation of duties, access rights to the HR system (PeopleHub) and payroll system (Sage Payroll), leaver process and access revocation, overtime and exception payment approvals, and completeness of personnel file documentation. Findings: (1) High risk — payroll system access was not revoked for 12 former employees within the required 24-hour window; the longest outstanding revocation was 47 days for a leaver from the Edinburgh office; management has since revoked all 12 accounts and implemented a weekly leaver-access report. (2) Medium risk — the overtime approval process was bypassed in 3 instances in February 2026, with overtime paid on manager verbal approval without documented sign-off; control has been reinforced with mandatory workflow in the system. (3) Low risk — annual salary review documentation was incomplete for 8 staff (missing signed forms or calibration notes); HR has committed to completing files by 30 April 2026. Overall opinion: Partially Satisfactory. Include an executive summary, detailed findings with criteria and recommendations, management response for each finding, and sign-off from the Head of Internal Audit. Report date: 10 April 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## board_minutes
|
||||||
|
Create board minutes for Hartwell Group plc. Meeting of the Board of Directors held on 15 April 2026 at 9:00am at the Company's offices at 100 Fleet Street, London EC4Y 1DE. Directors present: Sir Michael Hartwell (Chair), Catherine Ashworth, David Okonkwo, Fiona Reid, James Park. In attendance: Rachel Dunmore (incoming CFO, for item 2), company secretary Patricia Lowe. Apologies: none. The Chair confirmed a quorum and opened the meeting. (1) Minutes of the previous meeting (18 March 2026) were approved as a true record. (2) Q1 2026 financial results: The CFO (outgoing) presented the unaudited Q1 figures. Revenue £18.2M (up 8% YoY), EBITDA £4.2M (up 12% YoY), net debt £6.1M. The Board discussed the performance and noted strength in the UK and Nordic regions. Resolution: to approve the Q1 2026 financial results and their release to the market. Passed unanimously. (3) Appointment of CFO: The Chair proposed the appointment of Rachel Dunmore as Chief Financial Officer with effect from 1 May 2026, on terms set out in the board paper. One director abstained citing a prior professional relationship; the resolution was passed 4–1. (4) Acquisition pipeline: The CEO presented three potential acquisition targets (Meridian Analytics Ltd, and two others in confidence). The Board agreed to approve proceeding to due diligence on Meridian Analytics Ltd only, with a cap of £150K on diligence costs, and to receive an update at the May board. (5) Any other business: none. Next meeting: 20 May 2026. The meeting closed at 10:45am. Include standard wording for distribution and signing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## budget_proposal
|
||||||
|
Create a budget proposal for Crestwood Technologies Ltd for financial year 2026/27 (1 April 2026 to 31 March 2027). Purpose: to seek Board approval for the operating budget. Total proposed budget: £3.4M. Revenue projections: SaaS subscriptions £2.1M (assuming 18% ARR growth and two new enterprise customers signed in Q1), professional services £800K, support and maintenance contracts £500K. Major expense lines: engineering and product headcount £1.2M (current team of 12, no new hires in budget), cloud infrastructure and tooling £340K, sales and marketing £620K (including one new SDR and conference spend), G&A £480K (finance, legal, office), R&D and innovation £360K. Contingency: £100K unallocated. Key assumptions: 18% ARR growth, churn not to exceed 5% annualised, professional services margin held at 35%. Risks: delayed enterprise deal could reduce revenue by £200K; mitigation is pipeline acceleration and cost holdbacks after Q2 review. Include a one-page executive summary, revenue and cost breakdown tables, and a request for approval with date and authority. Document owner: Finance Director. Version 1.2, dated 12 March 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## case_study
|
||||||
|
Create a case study (2–3 pages) for Stirling PDF. Client: Nexus Cloud Ltd, a mid-market financial services firm based in London with 280 employees and operations in the UK and Ireland. Their challenge: manual document processing for client onboarding, KYC packs, and regulatory filings was consuming approximately 3 FTE and averaging 4 days per document, with a high error rate and poor visibility for compliance. They piloted Stirling PDF's AI document generation platform in Q3 2025 and rolled out to the operations and legal teams in Q4 2025. Solution: integrated Stirling PDF with their existing CRM and document store; used AI-assisted templates for standard document types; introduced approval workflows and audit trails. Results after 6 months: average processing time reduced from 4 days to 45 minutes for standard documents; the 3 FTE were redeployed to exception handling and client service; compliance error rate in sampled audits dropped by 94%; time to onboard a new corporate client reduced from 12 days to 4 days. Include a short quote/testimonial from their CTO, Marcus Reid: "Stirling PDF didn't just speed things up — it gave us control and traceability we never had with spreadsheets and email." Add a "Why Nexus Cloud chose Stirling PDF" section and a call-to-action for readers. Format for use on the Stirling PDF website and sales collateral.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## committee_agenda
|
||||||
|
Create an agenda for the Audit and Risk Committee of Meridian Healthcare NHS Foundation Trust. Meeting: 26 March 2026, 10:00am–12:00pm, Trust Headquarters, Bristol (Room 4.2, and via MS Teams for remote attendees). Chair: Non-Executive Director Dr. Anne Whitfield. Attendees: all Audit and Risk Committee members (as per standing list), Director of Finance (Sarah Okonkwo), Deputy Director of Finance, Head of Internal Audit, and representatives from external auditors Grant Thornton (lead partner James Hartley, manager Emma Chen). Apologies to be notified to the Committee Secretary. Agenda: (1) Welcome and declarations of interest (5 mins). (2) Minutes of the previous meeting (14 January 2026) — for approval (5 mins). (3) External audit progress update — Grant Thornton to report on 2025/26 audit progress, key risks, and timetable (20 mins). (4) Internal audit — Q4 findings and annual report summary; Head of Internal Audit to present (25 mins). (5) Risk register review — updated strategic and operational risks; discussion of any new or escalated risks (20 mins). (6) Financial controls update — Director of Finance to update on control improvements and any incidents (15 mins). (7) Any other business (5 mins). (8) Date of next meeting: 21 May 2026. Papers circulated with the agenda: Q4 internal audit report, updated risk register (version 3.2), management accounts to February 2026, external audit progress report. Contact: Committee Secretary, ext. 4521.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## employee_handbook
|
||||||
|
Create an employee handbook for Northgate Engineering Services Ltd, effective 1 January 2026. The company is an engineering consultancy with offices in Birmingham and Leeds; the handbook applies to all UK employees. Include: (1) Welcome and introduction from the Managing Director; company values and mission. (2) Working hours: standard 37.5 hours per week; flexible start between 7:30am and 9:30am; core hours 10am–3pm; break entitlements. (3) Annual leave: 25 days plus 8 bank holidays, rising to 28 days after 5 years' service; carry-over of up to 5 days with manager approval; booking procedure. (4) Sick leave: self-certification for up to 7 days; company sick pay after 3 months' service for up to 3 months full pay, then 3 months half pay; notification and evidence requirements. (5) Code of conduct: integrity, respect, diversity and inclusion, anti-bribery, conflicts of interest, use of social media. (6) Health and safety: responsibilities of the company and employees; reporting accidents and near misses; DSE and risk assessments. (7) IT and data security: acceptable use, passwords, data protection (UK GDPR), confidential information, bring-your-own-device policy. (8) Disciplinary and grievance procedures: informal and formal stages; right to be accompanied; appeals. (9) Other: probation (6 months), expenses, training and development, equal opportunities, maternity/paternity and other family leave (summary). State that the handbook is not contractual and may be updated; governed by UK employment law. Include a sign-off page for employees to acknowledge receipt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## executive_summary
|
||||||
|
Create an executive summary (2–3 pages) for the proposed acquisition of Trident Manufacturing Group Ltd by Aldgate Capital Partners LLP. Deal: Aldgate proposes to acquire 100% of the equity of Trident for £28M (enterprise value), with £3M of cash and £25M of assumed debt and seller rollover. Trident: UK-based precision engineering and manufacturing group; revenue £12M (last 12 months), EBITDA £2.1M, 3 manufacturing sites in the Midlands (Birmingham, Coventry, Wolverhampton), 180 employees. Key findings from commercial and financial due diligence: (1) Strong order book covering 14 months of production; key customers in aerospace and defence. (2) Aging plant and machinery; capex of approximately £1.8M required in year 2 for replacement and compliance. (3) Two key customer contracts (c. 30% of revenue) up for renewal in Q3 2026; renewal risk is material. (4) Management team is strong; retention packages recommended. (5) No material environmental or litigation issues identified. Recommendation: proceed with the acquisition subject to (a) capex allowance of £1.8M reflected in price or vendor warranty, and (b) earnout or deferred consideration linked to renewal of the two key contracts. Indicative timetable: exclusivity to 30 April, completion by end June 2026. Prepared by Aldgate deal team. Confidential. Date: 8 April 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## expense_report
|
||||||
|
Create an expense report for James Okafor, Sales Director at Pinnacle Cloud Services Ltd. Employee ID: EMP-10482. Department: Sales. Period: 1–28 February 2026. Expenses: (1) Client dinner at The Ivy, London on 12 February 2026 — £347.50; purpose: client entertainment with Meridian Solutions (4 attendees: James Okafor, 2 from Meridian, 1 from Pinnacle); receipt attached. (2) Train travel London Euston to Manchester Piccadilly and return on 18 February 2026 — £214.00; purpose: client meeting at Caldwell Retail Group; booking reference attached. (3) Hotel — Premier Inn Manchester Central, 18 February 2026 (one night) — £189.00; receipt attached. (4) Taxi and ground transport — various dates across February (airport transfers and client meetings) — £67.40; summary and receipts attached. Total expenses: £817.90. Currency: GBP. No advances received for this period. Declaration: I confirm that these expenses were incurred in the course of business and comply with the company expense policy. Submit for approval to: Karen Delgado, Director of Operations. Payment: reimbursement to bank account ending 8821 within 10 working days of approval. Include space for approver signature and date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## incident_report
|
||||||
|
Create an incident report for Ironbridge Engineering Solutions Ltd. Report reference: INC-2026-031. Date of incident: 6 March 2026. Time: 2:15pm. Location: Workshop Bay 3, Ironbridge Manufacturing Site, Telford. Reported by: Site Supervisor David Chen. Person involved: Thomas Walsh, Warehouse Operative (Employee ID 2047), 3 years' service. Summary: Mr Walsh sustained a back injury while manually lifting a 40kg component (engine housing) in Workshop Bay 3. He felt immediate pain and was unable to continue; first aid was administered on site by trained first aider Fiona Reid; he was then transported to A&E at Princess Royal Hospital, Telford. Diagnosis: lower back strain; he was discharged with advice and signed off work for 2 weeks. The incident is RIDDOR-reportable (non-fatal injury to an employee); the HSE was notified within the required timeframe. Root cause analysis: (1) No mechanical lifting aid (pallet jack or crane) was available in Bay 3; the nearest pallet jack was in Bay 1. (2) Mr Walsh had not received refresher manual handling training since 2023. (3) Risk assessment for Bay 3 had not been updated to reflect the regular movement of 40kg+ items. Corrective actions: (1) Procure and install two additional pallet jacks for Bay 3 by 20 March 2026. (2) Mandatory refresher manual handling training for all warehouse and workshop staff by 30 April 2026. (3) Update risk assessment for Workshop Bay 3 and implement a check before manual lift of items over 25kg. (4) Brief all relevant staff on the incident and lessons learned by 13 March 2026. Report prepared by: Health & Safety Manager. Approved by: Site Director. Date: 10 March 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## independent_contractor_agreement
|
||||||
|
Create an independent contractor agreement between Vantage Digital Solutions Ltd (Company Number 11223344), registered office 50 Tech Park, Edinburgh EH12 9AB (the "Client"), and Owen Clarke Consulting Ltd (Company Number 55667788), registered office 22 Consultant Lane, Glasgow G1 1AA (the "Contractor"). Commencement: 1 March 2026. Term: 6 months, unless terminated earlier. Services: the Contractor will provide senior backend engineering services (Python, AWS, system design) as directed by the Client's Engineering Manager, including code development, code review, and technical documentation. The Contractor will provide personnel as agreed in statements of work; key resource: Owen Clarke or an agreed substitute. Time commitment: up to 5 days per week; remote working with one day per week on-site at the Client's Edinburgh office (travel expenses reimbursed in line with policy). Day rate: £650 per day plus VAT; invoiced monthly in arrears; payment within 30 days. The Contractor is responsible for its own tax, NI, and insurance. Intellectual property: all IP and work product created in performing the services is assigned to the Client; the Contractor will execute any further documents required. Confidentiality: both parties will keep confidential all non-public information; obligation survives termination for 2 years. Non-solicitation: for 90 days after the end of the engagement, the Contractor will not solicit or hire the Client's employees or contractors. Termination: either party may terminate on 4 weeks' written notice; the Client may terminate for material breach with 14 days' notice to remedy. The agreement is governed by Scots law. Include signature blocks and schedule for statements of work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## invoice
|
||||||
|
Create an invoice from Blackthorn Digital Ltd (VAT Registration GB 123 4567 89), 15 Design Quarter, London SE1 2AB, to Meridian Solutions plc, 100 Commerce House, Manchester M2 5AB. Invoice number: INV-2026-047. Invoice date: 28 February 2026. Payment due: 29 March 2026 (30 days). Purchase order reference (if provided by customer): PO-MERI-2026-012. Line items: (1) UX design sprint — 40 hours at £125.00 per hour — £5,000.00; (2) Brand guidelines document — 1 deliverable — £1,200.00; (3) Monthly retainer February 2026 — £2,500.00. Subtotal: £8,700.00. VAT at 20%: £1,740.00. Total due: £10,440.00. Payment by bank transfer: Barclays Bank, sort code 20-00-00, account number 12345678. Reference: INV-2026-047 and your company name. Late payment: interest may be charged in accordance with the Late Payment of Commercial Debts (Interest) Act 1998. Blackthorn Digital Ltd is a company registered in England and Wales, number 09876543. Include standard terms reference (e.g. "Subject to our standard terms of business") and a thank-you message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## job_description
|
||||||
|
Create a job description for a Lead Software Engineer at Hartwell Digital Agency Ltd. Location: Manchester (Hybrid — 3 days per week in the office, 2 days remote). Reports to: Head of Engineering. Salary: £75,000–£90,000 per annum depending on experience. Benefits: 25 days holiday plus bank holidays, pension (5% employer contribution), private health insurance, annual training budget £2,000, cycle-to-work scheme. Role purpose: to lead a small team of backend engineers in designing, building, and maintaining scalable services and APIs for client projects and internal products; to contribute to architecture decisions and technical standards. Key responsibilities: lead and mentor a team of 3–5 engineers; own the technical design and delivery of backend features; conduct code reviews and ensure quality and security; work closely with product and design to define requirements and timelines; contribute to hiring and onboarding. Requirements: 5+ years of commercial backend engineering experience; strong proficiency in Python and Go; experience with AWS or GCP (EC2, Lambda, RDS, S3 or equivalent); experience leading or mentoring engineers; comfortable with CI/CD (e.g. GitHub Actions, Jenkins), infrastructure as code (Terraform or similar), and agile delivery. Nice to have: experience in agency or professional services; familiarity with React or front-end integration. Closing date for applications: 13 March 2026. Equal opportunities employer. Include application instructions (CV and short cover letter to careers@hartwelldigital.co.uk, quoting reference LSE-2026-01).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## letter
|
||||||
|
Write a formal business letter from Sarah Whitfield, Head of Procurement, Pinnacle Retail Group plc, 200 Retail Way, Birmingham B12 8AB (tel. 0121 123 4567, sarah.whitfield@pinnacleretail.co.uk), to the Account Manager, Apex Logistics Ltd, Apex House, 50 Distribution Park, Northampton NN3 8AB. Date: 15 February 2026. Subject: Notice of termination — warehousing contract. The letter should formally notify Apex that Pinnacle is exercising its 90-day termination clause under the current warehousing and distribution contract (contract reference: PRG-APEX-2022-01), with effect from 31 May 2026. The reason for termination is a strategic decision by Pinnacle to bring warehousing operations in-house following the opening of their new distribution centre in Coventry. The letter should thank Apex for the 4-year business relationship and the quality of service provided, and request a transition meeting within the next 14 days to agree handover steps, including inventory reconciliation, return of any Pinnacle assets, and final account settlement. Ask for confirmation of receipt and the name of the person who will lead the transition on Apex's side. Formal tone; sign off "Yours sincerely" with Sarah Whitfield's name and title. Letterhead style (company name and address at top).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## letter_of_intent
|
||||||
|
Create a letter of intent from Meridian Capital Advisors LLP (address: 75 Capital Square, London EC2V 7AB) to the Board of Directors of Trident Manufacturing Group Ltd (Trident House, Birmingham B6 4AB). Date: 1 April 2026. Subject: Proposed equity investment. Meridian expresses its non-binding interest in investing £3.5M in Trident in return for a 22% equity stake, on a pre-money valuation of £16M. The investment would be used primarily to fund expansion of Trident's Birmingham facility and the implementation of a new ERP system. Indicative timeline: exclusivity period of 30 days from signing of this LOI; due diligence (commercial, financial, legal) to be completed within 45 days; target completion by 30 June 2026. The LOI is non-binding except for: (1) confidentiality — both parties will keep the existence and terms of the LOI and any shared information confidential; (2) exclusivity — Trident will not solicit or entertain other offers for the duration of the exclusivity period; (3) costs — each party bears its own costs. Binding terms would be set out in definitive investment documentation. Governing law: English law. Request that Trident countersign to indicate its willingness to proceed on this basis. Include signature blocks for both parties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## master_services_agreement
|
||||||
|
Create a master services agreement between Pinnacle Cloud Services Ltd (Company Number 11223344), registered office 10 Cloud Street, Reading RG1 2AB ("Provider"), and Caldwell Retail Group plc (Company Number 09876543), registered office 5 High Street, Leeds LS1 1AA ("Client"). Services: the Provider will supply (1) cloud infrastructure management (AWS/Azure as agreed per order), (2) 24/7 monitoring and alerting, and (3) monthly security audits and reporting. Services will be specified in order forms or statements of work under this MSA. Fees: £18,000 per month (excluding VAT) for the baseline scope as set out in Order Form #1; additional services at agreed rates. Fees are reviewed annually with effect from each anniversary of the effective date. Term: initial term 2 years from the effective date; thereafter auto-renewing for successive 12-month periods unless either party gives at least 90 days' notice before the end of the then-current term. Data and privacy: the Provider processes personal data on behalf of the Client as a data processor under UK GDPR; a data processing addendum is incorporated. Liability: cap at 12 months of fees paid by the Client in the 12 months preceding the claim; no liability for indirect or consequential loss. Termination: for material breach (30 days to remedy); for convenience after the initial term on 30 days' written notice. Effects of termination: return of data, payment for services to date. Governing law and jurisdiction: English law, exclusive jurisdiction of the courts of England and Wales. Include entire agreement, force majeure, notices, and signature blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## meeting_minutes
|
||||||
|
Create meeting minutes for the Q1 Product Review meeting of Stirling PDF SaaS. Date: 5 March 2026. Time: 10:00am–11:30am. Location: Zoom (link sent in calendar invite). Chair: Ethan (CEO). Attendees: Ethan (CEO), Rachel (Product), Marcus (Engineering), Fiona (Design), James (Sales). Apologies: none. (1) Welcome and agenda — Ethan confirmed the focus on Q1 delivery and Q2 planning. (2) Product roadmap review — Rachel presented the updated roadmap. Key decisions: (a) Ship AI document generation v2 by 31 March 2026; scope confirmed (templates, streaming preview, PDF export). (b) Defer mobile app to Q3 2026; focus remains web and API. (c) Approve £40K budget for the planned security audit (Q2). (3) Engineering update — Marcus reported API rate limiting design is 80% complete; to be finalised and documented by 12 March. (4) Design — Fiona to deliver updated design system components (buttons, forms, modals) by 14 March for integration. (5) Sales — James noted two enterprise pilots in progress; no change to Q1 revenue forecast. (6) Action items: Marcus — finalise API rate limiting spec and share by 12 March. Rachel — update roadmap in Notion by 7 March and circulate. Fiona — design system components by 14 March. (7) Next meeting: 2 April 2026, 10:00am. Meeting closed at 11:28am. Distribution: attendees and leadership team. Include "Draft" or "Final" as appropriate and space for chair sign-off.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## nondisclosure_agreement
|
||||||
|
Create a mutual nondisclosure agreement between Apex Renewables Ltd (Company Number 11223344), 30 Green Lane, Bristol BS1 2AB ("Party A"), and Greenfield Energy Partners Ltd (Company Number 55667788), 15 Energy Place, London EC2A 1BB ("Party B"). Purpose: both parties are sharing commercially sensitive information (including technical, financial, and commercial data) to evaluate a potential joint venture for the development and operation of a 50MW solar PV project in Somerset (the "Project"). Definition of Confidential Information: all non-public information disclosed in connection with the Project, whether in writing, orally, or otherwise, and marked or reasonably identifiable as confidential. Exclusions: information that is or becomes publicly available (other than by breach), independently developed, or rightfully received from a third party without restriction. Obligations: each party will hold the other's Confidential Information in confidence, use it only for evaluating and negotiating the Project, and not disclose it except to advisers and personnel on a need-to-know basis under equivalent obligations. Term: 3 years from the date of signing. Return/destruction of materials on request or at end of purpose. Governing law: England and Wales. No licence or obligation to proceed with the Project. Effective date: 1 March 2026. Include signature blocks for both parties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## offer_letter
|
||||||
|
Create a job offer letter from Hartwell Digital Agency Ltd, 25 Digital Way, Manchester M1 4BT, to James Okafor, [home address to be inserted]. Position: Lead Software Engineer. Start date: 4 May 2026. Salary: £82,000 per annum, paid monthly in arrears by BACS. Benefits: 25 days annual leave plus bank holidays; private health insurance (after 3 months); pension with 5% employer contribution (employee minimum 3%); annual training and development budget of £2,000. You will report to the Head of Engineering and your principal place of work will be our Manchester office, with hybrid working (3 days in office, 2 days remote) as per our policy. This offer is contingent on: (1) satisfactory references (we will request two, one of which should be from your current or most recent employer); (2) confirmation of your right to work in the UK; (3) no material conflict of interest. Please confirm acceptance in writing by 28 February 2026. If you accept, you will receive a contract of employment and new starter paperwork. We look forward to welcoming you to the team. Sign off from the Head of People or Managing Director. Include space for the candidate's signature and date of acceptance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## official_memo
|
||||||
|
Write an official memo from Ethan Scott, CEO, Stirling PDF, to all staff. Date: 1 March 2026. Subject: Company-wide AI usage policy — update effective 1 April 2026. Distribution: all employees and contractors. The memo should state that the company is updating its AI usage policy to protect our clients, our IP, and our compliance posture. Key points: (1) Approved AI tools — only the internal Stirling AI assistant (our own product) and GitHub Copilot (for code) may be used for work. No other external generative AI tools (e.g. public ChatGPT, Claude, etc.) are permitted for company or client work. (2) Customer and confidential data — you must not enter any customer data, PII, or confidential business information into external AI services. Use only approved tools in their approved, compliant configurations. (3) Mandatory training — all staff must complete the new "AI at Stirling" training module in the LMS by 31 March 2026. Incomplete training will be escalated to line managers. (4) AI usage log — for all client-facing work that involves AI-assisted output, you must log the use (tool, date, document/project) in the new AI usage register. This supports our audit and client assurance. Questions to people@stirlingpdf.com. The policy document is available on the intranet. Thank you for your cooperation. Formal memo format; sign off from Ethan Scott, CEO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## pay_stub
|
||||||
|
Create a pay stub for Thomas Harrison, Senior Software Engineer, Meridian Digital Ltd, 40 Software Park, Manchester M2 1AB. Employee ID: EMP-00247. Department: Engineering. Pay period: 1 January 2026 to 31 January 2026. Pay date: 31 January 2026. Earnings: basic salary (monthly) £5,200.00; overtime 5 hours at £65.00/hour £325.00; total gross £5,525.00. Deductions: income tax (PAYE) £972.50; National Insurance (employee) £471.25; pension (employee 5%) £276.25; total deductions £1,720.00. Net pay: £3,805.00. Year to date: gross £5,525.00, tax £972.50, NI £471.25, pension £276.25, net £3,805.00. Payment method: BACS to Lloyds Bank, account ending 6742. Tax reference: 123/AB45678. Leave balance: 22 days remaining (as at 31 Jan). This is not a tax certificate; P60/P45 will be issued as applicable. Include company registration number and a note that the pay stub is confidential.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## performance_review
|
||||||
|
Create a performance review form for Priya Nair, Senior Business Analyst, Cavendish Financial Solutions Ltd. Review period: 1 January 2025 to 31 December 2025. Reviewer/Manager: David Hartley, Director of Consulting. Review date: 15 February 2026. Ratings (1–5 scale: 1 = Below Expectations, 2 = Needs Improvement, 3 = Meets Expectations, 4 = Exceeds Expectations, 5 = Outstanding): Communication — 4; Technical analysis — 5; Stakeholder management — 4; Delivery and execution — 4; Leadership — 3. Overall rating: Exceeds Expectations. Summary of accomplishments: led the delivery of the new client reporting platform, completing 2 weeks ahead of schedule and under budget; reduced standard report turnaround time by 60% through process and tooling improvements; supported 3 major client workshops with strong feedback. Areas for development: leadership and delegation; Priya has taken on more complex work but could develop by line-managing 1–2 junior analysts and leading a small workstream. Goals for 2026: (1) Build leadership skills — take on line management of two junior analysts by Q2; (2) Complete CBAP certification by end of 2026; (3) Lead at least one cross-team initiative. Employee comments (optional): [space for Priya to add]. Sign-off: employee and manager signatures and dates. Confidential — HR to retain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## price_sheet
|
||||||
|
Create a price sheet for Vantage Cloud Services Ltd. Document title: Vantage Cloud — Price List. Effective date: 1 March 2026. All prices are in GBP and exclude VAT unless stated. Products and services: Starter Plan (monthly, per seat) — £12; Growth Plan (monthly, per seat) — £29; Enterprise Plan (monthly, per seat) — £59; Professional Services (per day, onsite or remote) — £950; Data Migration (flat fee per migration project) — £2,500; Priority Support SLA (per month, in addition to plan) — £500; Custom Integrations — price on application (POA). Notes: minimum 12-month commitment for Growth and Enterprise; Professional Services sold in half-day blocks; Data Migration includes up to 50GB and one go-live support window. Discounts: annual prepay (10% off plan fees); volume discounts for 50+ seats on request. Contact: sales@vantagecloud.co.uk, 0800 123 4567. Company registration and VAT number at footer. "Prices subject to change; valid for new orders from the effective date."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## purchase_order
|
||||||
|
Create a purchase order from Crestwood Technologies Ltd, 60 Business Park, Bristol BS2 8CD (Buyer: Procurement, tel. 0117 123 4567), to Apex Office Supplies Ltd, Apex House, Northampton NN3 8AB. PO number: PO-2026-0312. Date: 10 March 2026. Delivery: requested by 20 March 2026 to Crestwood Technologies Ltd, 60 Business Park, Bristol BS2 8CD; contact: Reception. Payment terms: 30 days from date of invoice. Line items: (1) Ergonomic office chairs, model ErgoFlex Pro — quantity 10, unit price £320.00, line total £3,200.00; (2) Height-adjustable standing desks — quantity 4, unit price £540.00, line total £2,160.00; (3) Monitor arms (single, VESA compatible) — quantity 20, unit price £45.00, line total £900.00. Subtotal: £6,260.00. VAT at 20%: £1,252.00. Total: £7,512.00. This PO is subject to our standard terms of purchase (available on request). Please acknowledge receipt and confirm delivery date. Include space for supplier acceptance and reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## quote
|
||||||
|
Create a quote from Blackthorn Digital Ltd, 15 Design Quarter, London SE1 2AB, to Nexus Retail Solutions, 80 Retail Way, Leeds LS1 2AB. Quote number: QT-2026-089. Date: 15 February 2026. Valid for: 30 days (until 17 March 2026). Project: e-commerce redesign — discovery, design, frontend build, QA, and project management. Line items: Discovery and UX research (2 weeks, workshops and user research) — £4,800; UI design (12 key screens, responsive, design system) — £6,000; Frontend development (React, 8 weeks) — £14,400; QA and testing (2 weeks, UAT support) — £2,400; Project management (throughout) — £1,800. Total (excl. VAT): £29,400. VAT at 20%: £5,880. Total (incl. VAT): £35,280. Payment schedule: 30% on kick-off (£8,820 incl. VAT), 40% at mid-project milestone (£14,112 incl. VAT), 30% on delivery and sign-off (£10,584 incl. VAT). Assumptions: content and copy to be provided by client; backend API to be available by week 4; 2 rounds of design revisions included. Next steps: sign the quote and return to proceed; we will then send a contract and project plan. Contact: projects@blackthorndigital.co.uk. Include terms (e.g. scope change process, IP, liability) and signature/acceptance block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## receipt
|
||||||
|
Create a receipt from Blackthorn Digital Ltd (VAT GB 123 4567 89), 15 Design Quarter, London SE1 2AB, to Meridian Solutions plc, 100 Commerce House, Manchester M2 5AB. Receipt number: REC-2026-031. Date: 28 February 2026. This receipt confirms payment received from Meridian Solutions plc for the following: Invoice INV-2026-047 — UX design sprint, brand guidelines document, and monthly retainer February 2026. Amount received: £10,440.00 (including VAT). Payment method: BACS transfer. Payment reference: MERI-FEB26. Bank: as per our invoice. Thank you for your business. For any queries contact accounts@blackthorndigital.co.uk. Company registration: 09876543. Include "Paid in full" or similar and optional remittance advice reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## resume
|
||||||
|
Create a resume (CV) for Alexandra Moore, senior product manager, based in London. Contact: alexandra.moore@email.com, 07XXX XXXXXX. Summary: 8 years of experience in B2B SaaS product management; track record of launching and scaling products and leading cross-functional teams. Employment: (1) Head of Product, Stirling PDF, 2023–present — own product strategy and roadmap for the SaaS platform; lead a team of 4 product managers; launched AI document generation feature, contributing to 40% ARR growth in 12 months. (2) Senior Product Manager, Notion, 2020–2023 — owned Notion Templates and integrations roadmap; increased template adoption by 25%. (3) Product Manager, Intercom, 2018–2020 — worked on Messenger and automation products; shipped multiple features used by 10K+ customers. Education: BSc Computer Science, University of Edinburgh, 2015 (First Class Honours). Skills: product strategy, roadmap planning, SQL, Figma, Agile/Scrum, stakeholder management, pricing and packaging. Key achievement: led the launch of Stirling PDF's AI document generation feature from concept to GA; drove adoption and feedback loops that grew ARR by 40% in the first 12 months. Professional memberships or certifications: optional. Format: clear sections, reverse chronological order, one to two pages. Tone: professional and concise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## safe_agreement
|
||||||
|
Create a Simple Agreement for Future Equity (SAFE) between Helix Biotech Ltd (Company Number 11223344), 42 Innovation Way, Cambridge CB2 8AB (the "Company"), and Beacon Ventures Fund II LP, 100 Venture Street, London EC2A 1BB (the "Investor"). Investment amount: £500,000 (five hundred thousand pounds). Valuation cap: £8,000,000 (pre-money). Discount rate: 20%. The SAFE will convert into equity on the next qualifying equity financing (equity round with total proceeds of at least £1M), or on a liquidity event or dissolution. MFN (most favoured nation) clause: if the Company issues SAFEs with more favourable terms before conversion, this SAFE will be amended to match. Pro rata rights: the Investor will have the right to participate in the next round on the same terms, up to its pro rata share. Governing law: England and Wales. Effective date: 15 February 2026. Signed for the Company: Dr. Amelia Hargreaves, CEO. Signed for the Investor: Thomas Beacon, Managing Partner, Beacon Ventures Fund II LP. Include definitions (e.g. "Company Capitalisation", "Conversion"), conversion mechanics, and standard boilerplate (no representations, entire agreement, notices).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## separation_notice
|
||||||
|
Create a separation notice from Aldermoor Logistics plc, 25 Distribution Way, Bristol BS3 2AB, to Marcus Reid, Warehouse Operations Supervisor. Employee ID: EMP-30891. Department: Bristol Distribution Centre. Separation type: redundancy due to site consolidation (closure of Bristol distribution centre; operations moving to Birmingham). Effective date of termination: 28 February 2026. Final pay and entitlements: salary through 28 February 2026; 8 weeks' statutory redundancy pay (as calculated); payment in lieu of 5 days accrued untaken annual leave. Payment will be made on or before 28 February 2026 by BACS to your usual account. Benefits: private health cover will continue through 31 March 2026; you will receive separate communication regarding options thereafter. Equipment and access: you must return by 28 February 2026: company laptop, building access fob, and company vehicle keys (if applicable). Please arrange handover with your line manager Paul Fairweather. Reference: a reference letter will be provided by Paul Fairweather; you may request it from HR after your leaving date. Exit interview: HR will contact you to offer an optional exit interview. We thank you for your service and wish you well. Contact: HR at hr@aldermoorlogistics.co.uk. Include space for employee acknowledgment (optional) and company sign-off. Confidential.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## service_agreement
|
||||||
|
Create a service agreement between Pinnacle Cloud Services Ltd (Company Number 11223344), 10 Cloud Street, Reading RG1 2AB ("Provider"), and Crestwood Technologies Ltd (Company Number 55667788), 60 Business Park, Bristol BS2 8CD ("Client"). Services: (1) Managed IT support — helpdesk available 9am–6pm Monday–Friday (excluding UK bank holidays); ticket logging, triage, and resolution; (2) Monthly security patching — OS and application patches applied in agreed maintenance windows; (3) Quarterly business reviews — review of service performance, incidents, and roadmap. Scope: up to 50 users and 80 devices as specified in the order form. Monthly fee: £3,200 plus VAT; invoiced in advance; payment within 30 days. Term: 12 months from 1 April 2026; thereafter continuing until terminated. Termination: either party may give 3 months' written notice to take effect at or after the end of the initial term. SLA: P1 (critical) — response within 1 hour, target resolution 4 hours; P2 (high) — response within 4 hours, target resolution 1 business day; P3 (normal) — response within 1 business day. Service credits for repeated SLA failures as per schedule. Data and security: Provider will process Client data in accordance with agreed security standards and UK GDPR. Liability: cap at 12 months of fees. Governing law: England and Wales. Include change control, force majeure, and signature blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## standard_operating_procedures
|
||||||
|
Create a standard operating procedure (SOP) for incoming raw materials inspection at Meridian Pharma Manufacturing Ltd. SOP number: QA-RM-007. Version: 2.1. Effective date: 1 February 2026. Previous version: 2.0 (dated 1 June 2024). Purpose: to ensure that all incoming raw materials are inspected, sampled, and assessed against approved specifications before release to production, thereby preventing non-conforming materials from entering the manufacturing process. Scope: all raw materials received at the Meridian site (Warehouse Receipt Bay 1 and 2). Responsibilities: Warehouse — receipt and logging; QC — sampling and testing; QA — review and release/reject decision. Procedure: (1) Receipt and logging — on delivery, verify identity and quantity against the purchase order and delivery note; log in the ERP system (transaction GR-RM-001); assign a unique receipt number; place materials in the quarantine area. (2) Visual inspection — check packaging for damage, contamination, or temperature deviation (where applicable); record findings on form QA-RM-007-F1. (3) Sampling — per the approved sampling plan (QA-SP-002), take representative samples; send samples to QC lab with the request form; retain reserve samples. (4) QC testing — QC performs tests per the relevant specifications; results recorded in the LIMS and CoA generated. (5) Review — QA reviews the CoA and packaging inspection against the approved specification; if conforming, approve release in the ERP system (transaction REL-RM-001); if non-conforming, reject and initiate quarantine procedure (QA-RM-008). (6) Quarantine for rejected materials — move to rejected materials area; label clearly; document and notify supplier; disposition per QA-RM-008. References: QA-SP-002 (Sampling Plan), QA-RM-008 (Quarantine and Disposition), ERP user guides. Training: all personnel performing this procedure must be trained and assessed competent; training records in the training matrix. Approved by: Dr. Sarah Okonkwo, QA Manager. Date: 15 January 2026. Next review: 1 February 2027. Include revision history table and document control footer.
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Separation Notice</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
.letter-date {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin: 8pt 0 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 3pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-left: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separation-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
border-left: 3pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding: 2pt 8pt;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-block {
|
||||||
|
margin-top: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-closer {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-block { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Separation Notice</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Company Address:</span> <span class="meta-value">{{COMPANY_ADDRESS}}</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">{{LETTER_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Employee Information -->
|
||||||
|
<div class="section-heading">Employee Information</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Employee Name:</span> <span class="meta-value">{{EMPLOYEE_NAME}}</span>
|
||||||
|
<span class="meta-label">Employee ID:</span> <span class="meta-value">{{EMPLOYEE_ID}}</span>
|
||||||
|
<span class="meta-label">Position:</span> <span class="meta-value">{{POSITION}}</span>
|
||||||
|
<span class="meta-label">Department:</span> <span class="meta-value">{{DEPARTMENT}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Separation Details -->
|
||||||
|
<div class="section-heading">Separation Details</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Separation Type:</span> <span class="meta-value"><span class="separation-type-badge">{{SEPARATION_TYPE}}</span></span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-section">{{SEPARATION_REASON_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Final Pay -->
|
||||||
|
<div class="section-heading">Final Pay</div>
|
||||||
|
<div class="text-section">{{FINAL_PAY_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Benefits -->
|
||||||
|
<div class="section-heading">Benefits</div>
|
||||||
|
<div class="text-section">{{BENEFITS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Equipment Return -->
|
||||||
|
<div class="section-heading">Return of Company Property</div>
|
||||||
|
<div class="text-section">{{EQUIPMENT_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- References -->
|
||||||
|
<div class="section-heading">References Policy</div>
|
||||||
|
<div class="text-section">{{REFERENCES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Closing -->
|
||||||
|
<div class="section-heading">Closing Statement</div>
|
||||||
|
<div class="sig-block">
|
||||||
|
<div class="sig-closer">{{CLOSING_TEXT}}</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">HR Representative</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{HR_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{HR_SIGNER_TITLE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee Acknowledgement</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{EMPLOYEE_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{EMPLOYEE_ACK_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Separation Notice</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 3pt; margin-bottom: 8pt; border-left: 2pt solid var(--theme-accent); padding-left: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 9pt; }
|
||||||
|
.meta-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.separation-type-badge { display: inline-block; background: var(--theme-surface); border: 0.5pt solid var(--theme-border); border-left: 3pt solid var(--theme-accent); padding: 2pt 8pt; font-size: 9pt; font-weight: 600; color: var(--theme-heading); }
|
||||||
|
.text-section { font-size: 9.5pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 4pt; white-space: pre-line; }
|
||||||
|
.sig-block { margin-top: 14pt; }
|
||||||
|
.sig-closer { font-size: 9.5pt; line-height: 1.6; margin-bottom: 14pt; }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 8pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Separation Notice</div>
|
||||||
|
<div class="doc-subtitle">Aldermoor Logistics plc</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Company Address:</span> <span class="meta-value">12 Wharf Street, Bristol, BS1 4RN</span>
|
||||||
|
<span class="meta-label">Date:</span> <span class="meta-value">3 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="section-heading">Employee Information</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Employee Name:</span> <span class="meta-value">Marcus Reid</span>
|
||||||
|
<span class="meta-label">Employee ID:</span> <span class="meta-value">EMP-1134</span>
|
||||||
|
<span class="meta-label">Position:</span> <span class="meta-value">Warehouse Operations Supervisor</span>
|
||||||
|
<span class="meta-label">Department:</span> <span class="meta-value">Distribution & Fulfilment</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Separation Details</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Separation Type:</span> <span class="meta-value"><span class="separation-type-badge">Redundancy</span></span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">28 February 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-section">Following the completion of the company's operational restructuring review, the role of Warehouse Operations Supervisor at the Bristol Wharf site has been identified as redundant. This decision follows a full and fair consultation process conducted between 5 January and 28 January 2026. All reasonable steps to identify suitable alternative employment within the business were taken; regrettably, no suitable alternative position was available. This notice is therefore issued in accordance with your statutory and contractual rights.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Final Pay</div>
|
||||||
|
<div class="text-section">Your final salary payment, including all accrued but untaken annual leave (7 days, equivalent to £1,346.15), will be processed in the February 2026 payroll run and credited to your bank account on 28 February 2026. You are entitled to a statutory redundancy payment of £4,800, calculated on the basis of your continuous service of 6 years. A written breakdown of all final payments will be provided with your payslip.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Benefits</div>
|
||||||
|
<div class="text-section">Your private medical insurance cover through AXA Health will remain active until 28 February 2026. Pension contributions will cease on your effective leaving date; your pension provider, Nest, will write to you separately regarding the options available for your accumulated pension pot. Access to the employee assistance programme helpline will continue for 30 days following your leaving date.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Return of Company Property</div>
|
||||||
|
<div class="text-section">You are required to return all company property in your possession before or on 28 February 2026. This includes, but is not limited to: your company-issued access fob and car park pass, any keys to company premises or vehicles, your company mobile telephone and any associated accessories, and any confidential documents or records (whether in physical or digital form). Please arrange the return of these items with your line manager, Sandra Hughes, no later than 26 February 2026.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">References Policy</div>
|
||||||
|
<div class="text-section">The company's policy is to provide a factual reference confirming dates of employment and job title only. References should be requested through HR at [email protected]. The company will not provide references that include subjective assessments of performance.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Closing Statement</div>
|
||||||
|
<div class="sig-block">
|
||||||
|
<div class="sig-closer">We wish to thank Marcus for his contribution to Aldermoor Logistics over the past six years and wish him well in his future career. Should you have any questions regarding this notice or your entitlements, please contact the HR team at [email protected] or on 0117 900 4500.</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">HR Representative</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Rachel Thornton</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: HR Business Partner</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Employee Acknowledgement</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Marcus Reid</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 3 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Service Agreement {{AGREEMENT_NUMBER}}</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||||
|
margin: 6pt 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta grid ── */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130pt auto;
|
||||||
|
row-gap: 2pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parties ── */
|
||||||
|
.parties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-name {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-detail {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal sections ── */
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-heading {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-heading, #111827);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section-body {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature block ── */
|
||||||
|
.sig-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20pt;
|
||||||
|
margin-top: 16pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-col {
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-party-label {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--theme-accent, #2563eb);
|
||||||
|
margin-bottom: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Service Agreement</div>
|
||||||
|
<div class="doc-subtitle">Professional Services Contract</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Agreement Number:</span> <span class="meta-value">{{AGREEMENT_NUMBER}}</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">{{GOVERNING_LAW}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Parties -->
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Service Provider</div>
|
||||||
|
<div class="party-name">{{SERVICE_PROVIDER_NAME}}</div>
|
||||||
|
<div class="party-detail">{{SERVICE_PROVIDER_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Client</div>
|
||||||
|
<div class="party-name">{{CLIENT_NAME}}</div>
|
||||||
|
<div class="party-detail">{{CLIENT_ADDRESS}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal sections -->
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Scope of Services</div>
|
||||||
|
<div class="legal-section-body">{{SCOPE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Term and Termination</div>
|
||||||
|
<div class="legal-section-body">{{TERM_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Fees and Payment</div>
|
||||||
|
<div class="legal-section-body">{{FEES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">{{IP_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">{{CONFIDENTIALITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Warranties and Representations</div>
|
||||||
|
<div class="legal-section-body">{{WARRANTIES_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">{{LIABILITY_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Indemnification</div>
|
||||||
|
<div class="legal-section-body">{{INDEMNIFICATION_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Dispute Resolution</div>
|
||||||
|
<div class="legal-section-body">{{DISPUTE_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. General Provisions</div>
|
||||||
|
<div class="legal-section-body">{{GENERAL_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures -->
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Service Provider</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{PROVIDER_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{PROVIDER_SIGNER_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{PROVIDER_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Client</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: {{CLIENT_SIGNER_NAME}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: {{CLIENT_SIGNER_TITLE}}</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: {{CLIENT_SIGN_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Service Agreement</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { text-align: center; margin-bottom: 8pt; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 130pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||||
|
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||||
|
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||||
|
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||||
|
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||||
|
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||||
|
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||||
|
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||||
|
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||||
|
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||||
|
.sig-col { font-size: 8.5pt; }
|
||||||
|
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-title">Service Agreement</div>
|
||||||
|
<div class="doc-subtitle">Professional Services Contract</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-label">Agreement Number:</span> <span class="meta-value">SA-2026-0012</span>
|
||||||
|
<span class="meta-label">Effective Date:</span> <span class="meta-value">1 February 2026</span>
|
||||||
|
<span class="meta-label">Governing Law:</span> <span class="meta-value">England and Wales</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="section-heading">Parties</div>
|
||||||
|
<div class="parties-grid">
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Service Provider</div>
|
||||||
|
<div class="party-name">Apex Digital Solutions Ltd</div>
|
||||||
|
<div class="party-detail">45 Tech Park, Cambridge, CB1 3NF, UK<br>Company No: 09812345 · VAT: GB987654321</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="party-label">Client</div>
|
||||||
|
<div class="party-name">Meridian Retail Group plc</div>
|
||||||
|
<div class="party-detail">12 Commerce Square, Leeds, LS1 2AB, UK<br>Company No: 02345678</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Agreement</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">1. Scope of Services</div>
|
||||||
|
<div class="legal-section-body">The Service Provider agrees to deliver software development and technical consultancy services as described in Schedule A attached hereto, including bespoke e-commerce platform development, API integration with third-party logistics providers, and ongoing technical support during the warranty period specified therein.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">2. Term and Termination</div>
|
||||||
|
<div class="legal-section-body">This Agreement commences on the Effective Date and continues for a period of twelve (12) months unless earlier terminated. Either party may terminate this Agreement upon thirty (30) days' written notice. Immediate termination is permitted in the event of a material breach that remains uncured for ten (10) business days following written notice of such breach.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">3. Fees and Payment</div>
|
||||||
|
<div class="legal-section-body">The Client shall pay the Service Provider a monthly retainer of £8,500 (exc. VAT) due within 14 days of the invoice date. Additional project work will be billed at £125 per hour. All invoices unpaid after 30 days shall attract interest at 8% per annum above the Bank of England base rate under the Late Payment of Commercial Debts (Interest) Act 1998.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">4. Intellectual Property</div>
|
||||||
|
<div class="legal-section-body">All intellectual property created specifically for the Client under this Agreement shall vest in the Client upon receipt of full payment. The Service Provider retains all rights to pre-existing methodologies, tools, frameworks, and know-how used in delivering the services, and grants the Client a perpetual, royalty-free licence to use such elements solely as incorporated in the deliverables.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">5. Confidentiality</div>
|
||||||
|
<div class="legal-section-body">Each party shall hold the other's Confidential Information in strict confidence and shall not disclose it to any third party without prior written consent. This obligation survives termination of this Agreement for a period of three (3) years. Confidential Information excludes information that is or becomes publicly known through no breach of this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">6. Warranties and Representations</div>
|
||||||
|
<div class="legal-section-body">The Service Provider warrants that services will be performed with reasonable care and skill by suitably qualified personnel, and that deliverables will materially conform to agreed specifications for a period of ninety (90) days following acceptance. Each party warrants that it has full authority to enter into this Agreement.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">7. Limitation of Liability</div>
|
||||||
|
<div class="legal-section-body">Neither party's total liability under this Agreement shall exceed the total fees paid or payable in the twelve months preceding the event giving rise to the claim. Neither party shall be liable for indirect, consequential, special, or punitive damages, loss of profits, or loss of data, even if advised of the possibility of such damages.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">8. Indemnification</div>
|
||||||
|
<div class="legal-section-body">Each party (the "Indemnifying Party") shall indemnify and hold harmless the other party from and against any claims, damages, and costs arising from the Indemnifying Party's breach of this Agreement, negligence, or wilful misconduct, provided the indemnified party promptly notifies the Indemnifying Party and cooperates in the defence of such claims.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">9. Dispute Resolution</div>
|
||||||
|
<div class="legal-section-body">The parties shall attempt to resolve any dispute by good-faith negotiation for a period of thirty (30) days. If unresolved, the dispute shall be referred to mediation under the CEDR Model Mediation Procedure before recourse to litigation in the courts of England and Wales, which shall have exclusive jurisdiction.</div>
|
||||||
|
</div>
|
||||||
|
<div class="legal-section">
|
||||||
|
<div class="legal-section-heading">10. General Provisions</div>
|
||||||
|
<div class="legal-section-body">This Agreement constitutes the entire agreement between the parties and supersedes all prior representations. It may only be amended in writing signed by authorised representatives of both parties. If any provision is held unenforceable, the remainder of the Agreement shall continue in full force. This Agreement may not be assigned without prior written consent.</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-heading">Signatures</div>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Service Provider</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Daniel Ashworth</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Managing Director</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 1 February 2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-col">
|
||||||
|
<div class="sig-party-label">Client</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Signature</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Name: Victoria Pemberton</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Title: Chief Operating Officer</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Date: 1 February 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Standard Operating Procedure</title>
|
||||||
|
<style>
|
||||||
|
/* ── Theme variables (replaced at generation time) ── */
|
||||||
|
{{THEME_CSS}}
|
||||||
|
|
||||||
|
/* ── Print / page setup ── */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||||
|
font-size: 10pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
background: var(--theme-bg, #ffffff);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document {
|
||||||
|
max-width: 170mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{HEADER_CSS}}
|
||||||
|
|
||||||
|
/* ── Section heading ── */
|
||||||
|
.section-heading {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-primary, #1e3a5f);
|
||||||
|
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 10pt 0 6pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text sections ── */
|
||||||
|
.text-section {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Procedure steps (ol/li provided via placeholder) ── */
|
||||||
|
.procedure-steps {
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
padding-left: 16pt;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.procedure-steps li {
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Safety note box ── */
|
||||||
|
.safety-box {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
border-left: 3pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding: 6pt 10pt;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Revision history table ── */
|
||||||
|
.revision-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-table thead tr {
|
||||||
|
background: var(--theme-primary, #1e3a5f);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-table thead th {
|
||||||
|
padding: 4pt 6pt;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--theme-surface, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-table tbody td {
|
||||||
|
padding: 3pt 6pt;
|
||||||
|
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Approval block ── */
|
||||||
|
.approval-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110pt auto;
|
||||||
|
row-gap: 4pt;
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||||
|
padding-left: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-value {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: var(--theme-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-line {
|
||||||
|
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||||
|
margin: 8pt 0 3pt;
|
||||||
|
height: 14pt;
|
||||||
|
max-width: 180pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-field-label {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: var(--theme-text-muted, #6b7280);
|
||||||
|
margin-bottom: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page-break controls ── */
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||||
|
.procedure-steps li { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.approval-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* ── Print overrides ── */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.document { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="doc-header">
|
||||||
|
{{LOGO_BLOCK}}
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">{{SOP_TITLE}}</div>
|
||||||
|
<div class="doc-subtitle">{{COMPANY_NAME}} · SOP No: {{SOP_NUMBER}} · v{{VERSION}} · Effective: {{EFFECTIVE_DATE}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Purpose -->
|
||||||
|
<div class="section-heading">Purpose</div>
|
||||||
|
<div class="text-section">{{PURPOSE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Scope -->
|
||||||
|
<div class="section-heading">Scope</div>
|
||||||
|
<div class="text-section">{{SCOPE_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Definitions -->
|
||||||
|
<div class="section-heading">Definitions</div>
|
||||||
|
<div class="text-section">{{DEFINITIONS_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Roles & Responsibilities -->
|
||||||
|
<div class="section-heading">Roles and Responsibilities</div>
|
||||||
|
<div class="text-section">{{ROLES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Procedure -->
|
||||||
|
<div class="section-heading">Procedure</div>
|
||||||
|
<ol class="procedure-steps">
|
||||||
|
{{PROCEDURE_STEPS}}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<!-- Safety Notes -->
|
||||||
|
<div class="section-heading">Quality and Safety Notes</div>
|
||||||
|
<div class="safety-box">{{SAFETY_NOTES_TEXT}}</div>
|
||||||
|
|
||||||
|
<!-- Revision History -->
|
||||||
|
<div class="section-heading">Revision History</div>
|
||||||
|
<table class="revision-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:18%">Version</th>
|
||||||
|
<th style="width:28%">Date</th>
|
||||||
|
<th style="width:28%">Author</th>
|
||||||
|
<th>Summary of Changes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{REVISION_ROWS}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Approval -->
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<div class="approval-grid">
|
||||||
|
<span class="approval-label">Approved by:</span> <span class="approval-value">{{APPROVER_NAME}}</span>
|
||||||
|
<span class="approval-label">Title:</span> <span class="approval-value">{{APPROVER_TITLE}}</span>
|
||||||
|
<span class="approval-label">Date:</span> <span class="approval-value">{{APPROVAL_DATE}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Approver Signature</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Standard Operating Procedure</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-primary: #1e3a5f;
|
||||||
|
--theme-accent: #2563eb;
|
||||||
|
--theme-secondary: #475569;
|
||||||
|
--theme-bg: #ffffff;
|
||||||
|
--theme-surface: #f8fafc;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #1a1a1a;
|
||||||
|
--theme-text-muted: #6b7280;
|
||||||
|
--theme-heading: #111827;
|
||||||
|
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page { size: A4; margin: 20mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||||
|
.document { max-width: 170mm; margin: 0 auto; }
|
||||||
|
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||||
|
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||||
|
.doc-header-text { flex: 1; text-align: center; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||||
|
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||||
|
.sop-meta-bar { font-size: 8pt; color: var(--theme-text-muted); display: flex; gap: 14pt; margin-bottom: 8pt; }
|
||||||
|
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.text-section { font-size: 9pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 6pt; white-space: pre-line; }
|
||||||
|
.procedure-steps { font-size: 9pt; line-height: 1.6; color: var(--theme-text); padding-left: 16pt; margin-bottom: 6pt; }
|
||||||
|
.procedure-steps li { margin-bottom: 4pt; }
|
||||||
|
.safety-box { background: var(--theme-surface); border-left: 3pt solid var(--theme-accent); padding: 6pt 10pt; margin-bottom: 6pt; font-size: 9pt; line-height: 1.6; color: var(--theme-text); white-space: pre-line; }
|
||||||
|
.revision-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8pt; }
|
||||||
|
.revision-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||||
|
.revision-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 600; }
|
||||||
|
.revision-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||||
|
.revision-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||||
|
.approval-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 4pt; margin-bottom: 8pt; border-left: 2pt solid var(--theme-accent); padding-left: 8pt; }
|
||||||
|
.approval-label { font-weight: 600; color: var(--theme-text-muted); font-size: 9pt; }
|
||||||
|
.approval-value { font-size: 9pt; color: var(--theme-text); }
|
||||||
|
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin: 8pt 0 3pt; height: 14pt; max-width: 180pt; }
|
||||||
|
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 4pt; }
|
||||||
|
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="document">
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<div class="doc-header">
|
||||||
|
<div class="doc-header-text">
|
||||||
|
<div class="doc-title">Standard Operating Procedure</div>
|
||||||
|
<div class="doc-subtitle">Meridian Pharma Manufacturing Ltd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<p class="letter-date" style="font-size:9pt; color:var(--theme-text); margin-bottom:4pt; font-weight:600;">SOP: Incoming Raw Materials Inspection and Release</p>
|
||||||
|
<div class="sop-meta-bar">
|
||||||
|
<span>SOP No: QA-RM-007</span>
|
||||||
|
<span>Version: 2.1</span>
|
||||||
|
<span>Effective: 1 February 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Purpose</div>
|
||||||
|
<div class="text-section">This Standard Operating Procedure defines the process for receiving, inspecting, and releasing incoming raw materials and packaging components at the Meridian Pharma Manufacturing facility. The purpose is to ensure that all materials meet defined quality specifications before use in manufacturing operations and to maintain full traceability in compliance with GMP requirements.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Scope</div>
|
||||||
|
<div class="text-section">This procedure applies to all active pharmaceutical ingredients (APIs), excipients, and primary and secondary packaging materials delivered to the Swindon manufacturing site. It applies to all personnel involved in goods receipt, quality control testing, and material release activities.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Definitions</div>
|
||||||
|
<div class="text-section"><strong>GMP:</strong> Good Manufacturing Practice — regulatory guidelines governing the manufacture of pharmaceutical products.
|
||||||
|
<strong>CoA:</strong> Certificate of Analysis — document provided by the supplier confirming test results for a specific batch.
|
||||||
|
<strong>Quarantine:</strong> A temporary hold status applied to materials pending inspection and approval.
|
||||||
|
<strong>Release:</strong> The act of approving a material batch for use in manufacturing following satisfactory QC testing.
|
||||||
|
<strong>Deviation:</strong> Any departure from approved procedures or specifications.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Roles and Responsibilities</div>
|
||||||
|
<div class="text-section"><strong>Warehouse Operative:</strong> Receives deliveries, performs initial count and visual inspection, applies quarantine labels, and updates the ERP system.
|
||||||
|
<strong>Quality Control Analyst:</strong> Collects samples, performs or co-ordinates laboratory testing, and updates the LIMS with results.
|
||||||
|
<strong>QC Supervisor:</strong> Reviews test results against specifications and authorises material release or rejection.
|
||||||
|
<strong>Quality Assurance Manager:</strong> Approves deviations and oversees the overall integrity of the process.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Procedure</div>
|
||||||
|
<ol class="procedure-steps">
|
||||||
|
<li>Upon delivery, the Warehouse Operative verifies the delivery note against the purchase order in the ERP system. Any discrepancies in quantity, product code, or supplier must be recorded immediately and escalated to the QC Supervisor.</li>
|
||||||
|
<li>The Warehouse Operative performs a visual inspection of all outer packaging for signs of damage, contamination, or tampering. Damaged or suspect items are segregated and a Non-Conformance Report (NCR) is raised.</li>
|
||||||
|
<li>All accepted pallets and containers are labelled with a Quarantine sticker (yellow) and moved to the designated Quarantine area. No materials may leave the Quarantine area without QC authorisation.</li>
|
||||||
|
<li>The Warehouse Operative creates a goods receipt record in the ERP system, recording batch number, quantity, supplier, and date of receipt.</li>
|
||||||
|
<li>The QC Analyst collects representative samples in accordance with the approved sampling plan (refer to SOP QA-SP-003). Samples are transferred to the QC laboratory with a completed Sample Request Form.</li>
|
||||||
|
<li>The QC Analyst performs identity, purity, and specification testing in accordance with the approved test method. The supplier's CoA is reviewed and compared against internal specifications.</li>
|
||||||
|
<li>The QC Supervisor reviews all test results and the CoA. If all results comply with specifications, the Supervisor authorises release by updating the ERP status to "Approved" and replacing the Quarantine label with a green Released label.</li>
|
||||||
|
<li>If any result falls outside specification, the QC Supervisor initiates a deviation and places the batch on formal Reject status. The warehouse team moves the material to the Reject area. The Purchasing team is notified to arrange return or disposal with the supplier.</li>
|
||||||
|
<li>The QC Analyst files all records (test results, CoA, sample forms) in the batch record folder and archives a digital copy in the LIMS.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="section-heading">Quality and Safety Notes</div>
|
||||||
|
<div class="safety-box">All personnel handling raw materials must wear appropriate PPE as specified in the material Safety Data Sheet (SDS).
|
||||||
|
|
||||||
|
Materials must never be moved from Quarantine without written QC authorisation. Verbal instructions are not sufficient.
|
||||||
|
|
||||||
|
Cold-chain materials (storage below 8°C) must be inspected and transferred to temperature-controlled storage within 30 minutes of delivery. Temperature excursions must be reported immediately.
|
||||||
|
|
||||||
|
Any suspected counterfeit or adulterated material must be quarantined immediately and the QA Manager and Responsible Person notified without delay.</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Revision History</div>
|
||||||
|
<table class="revision-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:18%">Version</th>
|
||||||
|
<th style="width:28%">Date</th>
|
||||||
|
<th style="width:28%">Author</th>
|
||||||
|
<th>Summary of Changes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1.0</td>
|
||||||
|
<td>14 Mar 2023</td>
|
||||||
|
<td>A. Patel</td>
|
||||||
|
<td>Initial issue.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2.0</td>
|
||||||
|
<td>9 Jan 2025</td>
|
||||||
|
<td>S. Okonkwo</td>
|
||||||
|
<td>Updated sampling plan reference; added cold-chain handling requirements.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2.1</td>
|
||||||
|
<td>20 Jan 2026</td>
|
||||||
|
<td>S. Okonkwo</td>
|
||||||
|
<td>Revised ERP steps to reflect system upgrade; added digital LIMS archiving step.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-heading">Approval</div>
|
||||||
|
<div class="approval-grid">
|
||||||
|
<span class="approval-label">Approved by:</span> <span class="approval-value">Dr Sarah Okonkwo</span>
|
||||||
|
<span class="approval-label">Title:</span> <span class="approval-value">Quality Assurance Manager</span>
|
||||||
|
<span class="approval-label">Date:</span> <span class="approval-value">20 January 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<div class="sig-field-label">Approver Signature</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from functools import cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import FAST_MODEL
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import ChatMessage, DocTypeClassification
|
||||||
|
from prompts import document_type_classification_system_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _load_template_mapping() -> tuple[dict[str, Any], set[str]]:
|
||||||
|
"""Load template mapping JSON and extract available doctypes."""
|
||||||
|
# Get the directory where this file is located
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
template_mapping_path = os.path.join(current_dir, "template_mapping.json")
|
||||||
|
|
||||||
|
with open(template_mapping_path, encoding="utf-8") as f:
|
||||||
|
template_mapping = json.load(f)
|
||||||
|
|
||||||
|
# Extract all doctypes from the mapping
|
||||||
|
available_doctypes = {template.get("docType") for template in template_mapping.values() if template.get("docType")}
|
||||||
|
logger.info(
|
||||||
|
"[DOCTYPE] Loaded template mapping with %d doctypes",
|
||||||
|
len(available_doctypes),
|
||||||
|
)
|
||||||
|
|
||||||
|
return template_mapping, available_doctypes
|
||||||
|
|
||||||
|
|
||||||
|
# Document types that have format prompts (for AI-based extraction)
|
||||||
|
SUPPORTED_FORMAT_TYPES = {
|
||||||
|
"invoice",
|
||||||
|
"resume",
|
||||||
|
"cover_letter",
|
||||||
|
"contract",
|
||||||
|
"nda",
|
||||||
|
"meeting_agenda",
|
||||||
|
"quote",
|
||||||
|
"receipt",
|
||||||
|
"expense_report",
|
||||||
|
"terms_of_service",
|
||||||
|
"privacy_policy",
|
||||||
|
"proposal",
|
||||||
|
"report",
|
||||||
|
"letter",
|
||||||
|
"one_pager",
|
||||||
|
"statement_of_work",
|
||||||
|
"meeting_minutes",
|
||||||
|
"press_release",
|
||||||
|
"pay_stub",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_document_type(
|
||||||
|
prompt: str,
|
||||||
|
confidence_threshold: float = 0.7,
|
||||||
|
) -> tuple[str, float]:
|
||||||
|
"""
|
||||||
|
Detect document type using AI classification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: User's text prompt
|
||||||
|
latex_code: Optional LaTeX code to analyze
|
||||||
|
confidence_threshold: Minimum confidence (0-1) to accept a match from template list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (doc_type, confidence) where confidence is 0.0-1.0
|
||||||
|
"""
|
||||||
|
ai_type, confidence = _classify_with_ai(prompt, confidence_threshold)
|
||||||
|
if ai_type and ai_type != "other" and confidence >= confidence_threshold:
|
||||||
|
return ai_type, confidence
|
||||||
|
return ai_type or "other", confidence
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_with_ai(prompt: str, confidence_threshold: float = 0.7) -> tuple[str, float]:
|
||||||
|
"""
|
||||||
|
Use a FAST/CHEAP LLM to classify the document type from the user's prompt.
|
||||||
|
|
||||||
|
This uses FAST_MODEL (e.g., gpt-4.1-nano) for quick, cost-effective classification.
|
||||||
|
Returns a tuple of (doc_type, confidence) or None if classification fails.
|
||||||
|
"""
|
||||||
|
# Load available doctypes from template mapping
|
||||||
|
_, available_doctypes = _load_template_mapping()
|
||||||
|
|
||||||
|
# Build the list of available doctypes for the prompt
|
||||||
|
# Include both template mapping doctypes and legacy supported types
|
||||||
|
all_doctypes = sorted(list(available_doctypes | SUPPORTED_FORMAT_TYPES))
|
||||||
|
doctypes_list = ", ".join(all_doctypes) + ", other"
|
||||||
|
system_prompt = document_type_classification_system_prompt(doctypes_list)
|
||||||
|
messages = [
|
||||||
|
ChatMessage(role="system", content=system_prompt),
|
||||||
|
ChatMessage(role="user", content=prompt[:500]),
|
||||||
|
]
|
||||||
|
|
||||||
|
parsed = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
DocTypeClassification,
|
||||||
|
tag="doc_type_classify",
|
||||||
|
max_tokens=20,
|
||||||
|
)
|
||||||
|
content = parsed.doc_type.strip().lower()
|
||||||
|
content = content.replace("-", "_").replace(" ", "_")
|
||||||
|
content = content.split()[0] if content else ""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[DOCTYPE] Fast AI classification model=%s result=%s",
|
||||||
|
FAST_MODEL,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
# Check if the result is in available doctypes (from template mapping)
|
||||||
|
if content in available_doctypes:
|
||||||
|
# High confidence for template mapping matches
|
||||||
|
return content, 0.85
|
||||||
|
# Check if it's in legacy supported types
|
||||||
|
elif content in SUPPORTED_FORMAT_TYPES:
|
||||||
|
# Medium confidence for legacy types
|
||||||
|
return content, 0.75
|
||||||
|
elif content == "other":
|
||||||
|
return "other", 0.5
|
||||||
|
else:
|
||||||
|
# Unknown type - low confidence
|
||||||
|
logger.warning(f"[DOCTYPE] AI returned unknown type '{content}', falling back to 'other'")
|
||||||
|
return "other", 0.3
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"detect_document_type",
|
||||||
|
"SUPPORTED_FORMAT_TYPES",
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .routes import register_edit_routes
|
||||||
|
|
||||||
|
__all__ = ["register_edit_routes"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Confirmation intent classification during AWAITING_CONFIRM state.
|
||||||
|
CRITICAL: Prevents misexecution when user changes mind during confirmation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from config import FAST_MODEL
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import ChatMessage, ConfirmationAnswer, ConfirmationIntent
|
||||||
|
from models.tool_models import OperationId
|
||||||
|
from prompts import confirmation_intent_system_prompt, confirmation_question_system_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def classify_confirmation_intent(
|
||||||
|
message: str,
|
||||||
|
pending_plan_summary: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> ConfirmationIntent | None:
|
||||||
|
"""
|
||||||
|
Classify user intent during confirmation phase.
|
||||||
|
CRITICAL: This prevents misexecution when user changes mind.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- confirm: User agrees, execute plan
|
||||||
|
- cancel: User cancels, clear plan
|
||||||
|
- modify: User wants to change the plan (we'll clear + replan)
|
||||||
|
- new_request: User wants something different (clear + route as fresh)
|
||||||
|
- question: User asks about the plan (answer without executing)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"yes" → confirm
|
||||||
|
"cancel" → cancel
|
||||||
|
"actually delete page 7" → modify
|
||||||
|
"never mind, compress it" → new_request
|
||||||
|
"what will this do?" → question
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
- For "modify": We implement minimal safe behavior (clear + replan)
|
||||||
|
- No complex patching needed - just ensure old plan never executes
|
||||||
|
"""
|
||||||
|
system_prompt = confirmation_intent_system_prompt(pending_plan_summary)
|
||||||
|
messages = [ChatMessage(role="system", content=system_prompt)]
|
||||||
|
messages.extend(history[-3:]) # Last few messages for context
|
||||||
|
messages.append(ChatMessage(role="user", content=message))
|
||||||
|
|
||||||
|
decision = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
ConfirmationIntent,
|
||||||
|
tag="edit_confirmation_intent",
|
||||||
|
log_label="edit-confirmation-intent",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
def answer_confirmation_question(
|
||||||
|
question: str,
|
||||||
|
plan_summary: str,
|
||||||
|
operations: list[OperationId],
|
||||||
|
history: list[ChatMessage],
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Answer user's question about pending plan without executing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: User's question
|
||||||
|
plan_summary: Summary of pending plan
|
||||||
|
operations: Operation objects for details
|
||||||
|
history: Conversation history
|
||||||
|
session_id: Session ID for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Answer to user's question
|
||||||
|
"""
|
||||||
|
system_prompt = confirmation_question_system_prompt(plan_summary, operations)
|
||||||
|
messages = [ChatMessage(role="system", content=system_prompt)]
|
||||||
|
messages.extend(history[-3:])
|
||||||
|
messages.append(ChatMessage(role="user", content=question))
|
||||||
|
|
||||||
|
response = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
ConfirmationAnswer,
|
||||||
|
tag="edit_confirmation_question",
|
||||||
|
log_label="edit-confirmation-question",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
if response and response.message:
|
||||||
|
return response.message.strip()
|
||||||
|
raise RuntimeError("AI confirmation question response failed.")
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from models import PdfPreflight
|
||||||
|
from models.tool_models import OperationId
|
||||||
|
|
||||||
|
REQUIRED_CLARIFICATIONS = {
|
||||||
|
"removePassword": ["password"],
|
||||||
|
"deletePages": ["pageNumbers"],
|
||||||
|
}
|
||||||
|
|
||||||
|
DESTRUCTIVE_OPERATIONS = {
|
||||||
|
"removePassword": "This will remove all security from your PDF.",
|
||||||
|
"sanitize": "This will remove all metadata and hidden content.",
|
||||||
|
"flatten": "This will convert all form fields to static content (irreversible).",
|
||||||
|
"deletePages": "This will permanently delete the specified pages.",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_OPERATION_OVERRIDES = {
|
||||||
|
"addPageNumbers": {
|
||||||
|
"fontType": "times",
|
||||||
|
"position": 8,
|
||||||
|
"pageNumbers": "all",
|
||||||
|
"pagesToNumber": "all",
|
||||||
|
"customMargin": "medium",
|
||||||
|
"customText": "{n}",
|
||||||
|
},
|
||||||
|
"processPdfWithOCR": {
|
||||||
|
"languages": ["eng"],
|
||||||
|
"ocrType": "skip-text",
|
||||||
|
"ocrRenderType": "hocr",
|
||||||
|
},
|
||||||
|
"optimizePdf": {
|
||||||
|
"optimizeLevel": 6,
|
||||||
|
"grayscale": False,
|
||||||
|
"linearize": False,
|
||||||
|
"normalize": False,
|
||||||
|
},
|
||||||
|
"removeBlankPages": {
|
||||||
|
"threshold": 10,
|
||||||
|
"whitePercent": 95,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Risk policy table: Single source of truth for operation risk assessment
|
||||||
|
OPERATION_RISK_POLICY = [
|
||||||
|
# High risk - destructive content removal (always confirm)
|
||||||
|
{
|
||||||
|
"op": "deletePages",
|
||||||
|
"risk": "high",
|
||||||
|
"always_confirm": True,
|
||||||
|
"reason": "destructive content removal",
|
||||||
|
"warning": "This will permanently delete the specified pages.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "removePassword",
|
||||||
|
"risk": "high",
|
||||||
|
"always_confirm": True,
|
||||||
|
"reason": "removes all security",
|
||||||
|
"warning": "This will remove all security from your PDF.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "sanitize",
|
||||||
|
"risk": "high",
|
||||||
|
"always_confirm": True,
|
||||||
|
"reason": "removes metadata and hidden content",
|
||||||
|
"warning": "This will remove all metadata and hidden content.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "flatten",
|
||||||
|
"risk": "high",
|
||||||
|
"always_confirm": True,
|
||||||
|
"reason": "irreversible form field conversion",
|
||||||
|
"warning": "This will convert all form fields to static content (irreversible).",
|
||||||
|
},
|
||||||
|
# Medium risk - lossy transformations
|
||||||
|
{
|
||||||
|
"op": "optimizePdf",
|
||||||
|
"risk": "medium",
|
||||||
|
"always_confirm": False,
|
||||||
|
"reason": "lossy compression",
|
||||||
|
"confirm_if": lambda preflight: (preflight.file_size_mb or 0) > 50, # > 50MB
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "processPdfWithOCR",
|
||||||
|
"risk": "medium",
|
||||||
|
"always_confirm": False,
|
||||||
|
"reason": "may alter text layer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "extractImages",
|
||||||
|
"risk": "medium",
|
||||||
|
"always_confirm": False,
|
||||||
|
"reason": "creates derivative content",
|
||||||
|
},
|
||||||
|
# Low risk - non-destructive transformations
|
||||||
|
{
|
||||||
|
"op": "rotatePDF",
|
||||||
|
"risk": "low",
|
||||||
|
"always_confirm": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "splitPdf",
|
||||||
|
"risk": "low",
|
||||||
|
"always_confirm": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "mergePdfs",
|
||||||
|
"risk": "low",
|
||||||
|
"always_confirm": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "addPageNumbers",
|
||||||
|
"risk": "low",
|
||||||
|
"always_confirm": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "addWatermark",
|
||||||
|
"risk": "low",
|
||||||
|
"always_confirm": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_operation_risk(operation_id: OperationId, preflight: PdfPreflight | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Get risk assessment for operation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"level": "low" | "medium" | "high",
|
||||||
|
"reason": "...",
|
||||||
|
"should_confirm": bool,
|
||||||
|
"warning": "..." (if high risk)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
preflight = preflight or PdfPreflight()
|
||||||
|
|
||||||
|
for policy in OPERATION_RISK_POLICY:
|
||||||
|
if policy["op"] == operation_id:
|
||||||
|
should_confirm = policy.get("always_confirm", False)
|
||||||
|
|
||||||
|
# Check conditional confirmation
|
||||||
|
if not should_confirm and "confirm_if" in policy:
|
||||||
|
confirm_fn = policy["confirm_if"]
|
||||||
|
if callable(confirm_fn):
|
||||||
|
should_confirm = confirm_fn(preflight)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": policy["risk"],
|
||||||
|
"reason": policy.get("reason", ""),
|
||||||
|
"should_confirm": should_confirm,
|
||||||
|
"warning": policy.get("warning"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: assume low risk
|
||||||
|
return {
|
||||||
|
"level": "low",
|
||||||
|
"reason": "",
|
||||||
|
"should_confirm": False,
|
||||||
|
"warning": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_plan_risk(operation_ids: list[OperationId], preflight: PdfPreflight | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Assess combined risk for multiple operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation_ids: List of operation IDs in plan
|
||||||
|
preflight: File metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"level": "low" | "medium" | "high",
|
||||||
|
"reasons": [list of risk reasons],
|
||||||
|
"should_confirm": bool (if any op requires confirmation)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
risks = [get_operation_risk(op_id, preflight) for op_id in operation_ids]
|
||||||
|
|
||||||
|
# Highest risk level wins
|
||||||
|
if any(r["level"] == "high" for r in risks):
|
||||||
|
level = "high"
|
||||||
|
elif any(r["level"] == "medium" for r in risks):
|
||||||
|
level = "medium"
|
||||||
|
else:
|
||||||
|
level = "low"
|
||||||
|
|
||||||
|
# Multi-op with any high risk should confirm
|
||||||
|
should_confirm = len(operation_ids) > 1 and level == "high"
|
||||||
|
# Or any single op that always requires confirmation
|
||||||
|
should_confirm = should_confirm or any(r["should_confirm"] for r in risks)
|
||||||
|
|
||||||
|
reasons = [r["reason"] for r in risks if r["reason"]]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": level,
|
||||||
|
"reasons": reasons,
|
||||||
|
"should_confirm": should_confirm,
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import textwrap
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from config import FAST_MODEL
|
||||||
|
from file_processing_agent import ToolCatalogService
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import AskUserMessage, ChatMessage, DefaultsDecision, IntentDecision
|
||||||
|
from prompts import (
|
||||||
|
edit_defaults_decision_system_prompt,
|
||||||
|
edit_info_system_prompt,
|
||||||
|
edit_intent_classification_system_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wants_defaults(message: str, session_id: str | None = None) -> bool:
|
||||||
|
system_prompt = edit_defaults_decision_system_prompt()
|
||||||
|
messages = [
|
||||||
|
ChatMessage(role="system", content=system_prompt),
|
||||||
|
ChatMessage(role="user", content=message),
|
||||||
|
]
|
||||||
|
decision = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
DefaultsDecision,
|
||||||
|
tag="edit_defaults_decision",
|
||||||
|
log_label="edit-defaults-decision",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return decision.use_defaults
|
||||||
|
|
||||||
|
|
||||||
|
def classify_edit_intent(
|
||||||
|
message: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> IntentDecision | None:
|
||||||
|
system_prompt = edit_intent_classification_system_prompt()
|
||||||
|
messages = [ChatMessage(role="system", content=system_prompt)]
|
||||||
|
messages.extend(history)
|
||||||
|
messages.append(ChatMessage(role="user", content=message))
|
||||||
|
decision = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
IntentDecision,
|
||||||
|
tag="edit_intent_decision",
|
||||||
|
log_label="edit-intent-decision",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
def answer_conversational_info(
|
||||||
|
message: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
tool_catalog: ToolCatalogService,
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Handle conversational queries without files (greetings, help requests, capability questions)."""
|
||||||
|
selection_index = tool_catalog.build_selection_index()
|
||||||
|
|
||||||
|
system_instructions = textwrap.dedent("""\
|
||||||
|
Answer the user's question about capabilities.
|
||||||
|
Be friendly, clear, and helpful.
|
||||||
|
|
||||||
|
This system can:
|
||||||
|
1. Edit PDF files - compress, merge, split, rotate, watermark, OCR, convert, add security, and many more operations
|
||||||
|
2. Create new PDF documents - generate professional documents from descriptions (business proposals, reports, resumes, etc.)
|
||||||
|
3. Create smart folders - set up automated PDF processing workflows that run on uploaded files
|
||||||
|
|
||||||
|
If the user is greeting you (hello, hi, hey), respond warmly and briefly explain what you can help with.
|
||||||
|
If asking about capabilities (what can you do, help), provide a clear overview of all three main features.
|
||||||
|
For PDF editing questions, reference the available tools from the tool_catalog below.
|
||||||
|
Use bullets when listing multiple options.
|
||||||
|
Keep responses concise but informative.
|
||||||
|
Encourage them to upload a PDF to edit it, or ask to create a new document.
|
||||||
|
Do not mention session IDs, technical details, or backend concepts.
|
||||||
|
""").strip()
|
||||||
|
|
||||||
|
system_payload = {
|
||||||
|
"instructions": system_instructions,
|
||||||
|
"tool_catalog": [asdict(entry) for entry in selection_index],
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
ChatMessage(role="system", content=[system_payload]),
|
||||||
|
*history,
|
||||||
|
ChatMessage(role="user", content=message),
|
||||||
|
]
|
||||||
|
response = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
AskUserMessage,
|
||||||
|
tag="conversational_info_response",
|
||||||
|
log_label="conversational-info-response",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return response.message
|
||||||
|
|
||||||
|
|
||||||
|
def answer_edit_info(
|
||||||
|
message: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
file_name: str,
|
||||||
|
file_type: str | None,
|
||||||
|
tool_catalog: ToolCatalogService,
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
catalog_text = tool_catalog.build_catalog_prompt()
|
||||||
|
system_prompt = edit_info_system_prompt(file_name, file_type, catalog_text)
|
||||||
|
messages = [ChatMessage(role="system", content=system_prompt)]
|
||||||
|
messages.extend(history)
|
||||||
|
messages.append(ChatMessage(role="user", content=message))
|
||||||
|
response = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
AskUserMessage,
|
||||||
|
tag="edit_info_response",
|
||||||
|
log_label="edit-info-response",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
if response and response.message.strip():
|
||||||
|
return response.message.strip()
|
||||||
|
raise RuntimeError("AI edit info response failed.")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Custom exceptions for the editing module."""
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientCreditsError(Exception):
|
||||||
|
"""Raised when an operation is blocked due to insufficient credits."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int = 429, error_body: str = "", error_json: dict | None = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error_body = error_body
|
||||||
|
self.error_json = error_json or {}
|
||||||
|
super().__init__(f"Insufficient credits (HTTP {status_code})")
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
from werkzeug.security import safe_join
|
||||||
|
|
||||||
|
import analytics
|
||||||
|
import models
|
||||||
|
from config import OUTPUT_DIR
|
||||||
|
from file_processing_agent import ToolCatalogService
|
||||||
|
|
||||||
|
from .constants import assess_plan_risk, get_operation_risk
|
||||||
|
from .decisions import (
|
||||||
|
answer_edit_info,
|
||||||
|
classify_edit_intent,
|
||||||
|
)
|
||||||
|
from .operations import (
|
||||||
|
answer_pdf_question,
|
||||||
|
apply_smart_defaults,
|
||||||
|
build_pdf_text_context,
|
||||||
|
build_plan_summary,
|
||||||
|
create_session_file,
|
||||||
|
format_disambiguation_question,
|
||||||
|
get_pdf_preflight,
|
||||||
|
sanitize_filename,
|
||||||
|
validate_operation_chain,
|
||||||
|
)
|
||||||
|
from .session_store import EditSession, EditSessionFile, EditSessionStore, PendingOperation, PendingPlan
|
||||||
|
from .state_router import route_message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EditService:
|
||||||
|
def __init__(self, session_store: EditSessionStore, tool_catalog: ToolCatalogService) -> None:
|
||||||
|
self.sessions = session_store
|
||||||
|
self.tool_catalog = tool_catalog
|
||||||
|
self.edit_upload_dir = os.path.join(OUTPUT_DIR, "uploads")
|
||||||
|
|
||||||
|
def _strip_file_context_history(self, messages: list[models.ChatMessage]) -> list[models.ChatMessage]:
|
||||||
|
filtered: list[models.ChatMessage] = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.content
|
||||||
|
if isinstance(content, list):
|
||||||
|
new_content = [
|
||||||
|
item for item in content if not (isinstance(item, dict) and item.get("type") == "file_context")
|
||||||
|
]
|
||||||
|
if not new_content:
|
||||||
|
continue
|
||||||
|
if new_content == content:
|
||||||
|
filtered.append(msg)
|
||||||
|
else:
|
||||||
|
filtered.append(models.ChatMessage(role=msg.role, content=new_content))
|
||||||
|
else:
|
||||||
|
filtered.append(msg)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def _build_status_response(self, session: EditSession) -> ResponseReturnValue:
|
||||||
|
if session.file_path:
|
||||||
|
filename = os.path.relpath(session.file_path, OUTPUT_DIR)
|
||||||
|
response = models.EditMessageResponse(
|
||||||
|
assistant_message="",
|
||||||
|
result_file_url=f"/output/{filename}",
|
||||||
|
result_file_name=session.file_name,
|
||||||
|
result_files=[models.EditResultFile(url=f"/output/{filename}", name=session.file_name)],
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
response = models.EditMessageResponse(assistant_message="")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
def _primary_file(self, session: EditSession) -> EditSessionFile | None:
|
||||||
|
if session.files:
|
||||||
|
return session.files[0]
|
||||||
|
if session.file_path:
|
||||||
|
return EditSessionFile(
|
||||||
|
file_id="primary",
|
||||||
|
file_path=session.file_path,
|
||||||
|
file_name=session.file_name,
|
||||||
|
file_type=session.file_type,
|
||||||
|
preflight=session.preflight,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_file_context(self, session: EditSession) -> None:
|
||||||
|
if not session.file_path:
|
||||||
|
return
|
||||||
|
if session.file_context and session.file_context_path == session.file_path:
|
||||||
|
return
|
||||||
|
context = build_pdf_text_context(session.file_path)
|
||||||
|
session.file_context = context
|
||||||
|
session.file_context_path = session.file_path
|
||||||
|
message = models.ChatMessage(role="assistant", content=[context])
|
||||||
|
for index in range(len(session.messages) - 1, -1, -1):
|
||||||
|
if session.messages[index].role == "user":
|
||||||
|
session.messages.insert(index, message)
|
||||||
|
return
|
||||||
|
session.messages.append(message)
|
||||||
|
|
||||||
|
def create_session(self, files: list[FileStorage]) -> ResponseReturnValue:
|
||||||
|
if not files:
|
||||||
|
return jsonify({"error": "Missing file upload"}), 400
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session_files: list[EditSessionFile] = []
|
||||||
|
|
||||||
|
for index, file in enumerate(files):
|
||||||
|
original_name = sanitize_filename(file.filename or f"upload-{index + 1}.pdf")
|
||||||
|
extension = Path(original_name).suffix or ".pdf"
|
||||||
|
if extension.lower() != ".pdf":
|
||||||
|
return jsonify({"error": "Only PDF files are supported right now."}), 400
|
||||||
|
session_dir = os.path.join(OUTPUT_DIR, session_id)
|
||||||
|
os.makedirs(session_dir, exist_ok=True)
|
||||||
|
file_path = os.path.join(session_dir, original_name)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Create session file with proper type detection and preflight
|
||||||
|
session_file = create_session_file(
|
||||||
|
file_path=file_path,
|
||||||
|
file_name=original_name,
|
||||||
|
content_type=file.mimetype,
|
||||||
|
content_disposition=None,
|
||||||
|
)
|
||||||
|
session_files.append(session_file)
|
||||||
|
|
||||||
|
primary = session_files[0]
|
||||||
|
session = EditSession(
|
||||||
|
session_id=session_id,
|
||||||
|
file_path=primary.file_path,
|
||||||
|
file_name=primary.file_name,
|
||||||
|
file_type=primary.file_type,
|
||||||
|
preflight=primary.preflight,
|
||||||
|
files=session_files,
|
||||||
|
)
|
||||||
|
self.sessions.set(session)
|
||||||
|
|
||||||
|
page_counts = [
|
||||||
|
page_count for item in session_files if isinstance((page_count := item.preflight.page_count), int)
|
||||||
|
]
|
||||||
|
size_values = [
|
||||||
|
file_size_mb
|
||||||
|
for item in session_files
|
||||||
|
if isinstance((file_size_mb := item.preflight.file_size_mb), (int, float))
|
||||||
|
]
|
||||||
|
analytics.track_event(
|
||||||
|
user_id=session_id,
|
||||||
|
event_name="edit_session_created",
|
||||||
|
properties={
|
||||||
|
"session_id": session_id,
|
||||||
|
"file_count": len(session_files),
|
||||||
|
"total_pages": sum(page_counts),
|
||||||
|
"total_size_mb": round(sum(size_values), 2),
|
||||||
|
"has_text_layer": any(item.preflight.has_text_layer for item in session_files),
|
||||||
|
"has_encrypted": any(item.preflight.is_encrypted for item in session_files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = models.EditSessionResponse(
|
||||||
|
session_id=session_id,
|
||||||
|
file_name=primary.file_name,
|
||||||
|
file_type=primary.file_type,
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
def add_attachment(self, session_id: str, name: str | None, file: FileStorage | None) -> ResponseReturnValue:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return jsonify({"error": "Edit session not found"}), 404
|
||||||
|
if not file or not name:
|
||||||
|
return jsonify({"error": "Missing attachment or name"}), 400
|
||||||
|
|
||||||
|
original_name = sanitize_filename(file.filename or "attachment")
|
||||||
|
file_type = file.mimetype or mimetypes.guess_type(original_name)[0]
|
||||||
|
extension = Path(original_name).suffix or ""
|
||||||
|
attachment_id = uuid.uuid4().hex
|
||||||
|
stored_name = f"{session_id}-attachment-{attachment_id}{extension}"
|
||||||
|
os.makedirs(self.edit_upload_dir, exist_ok=True)
|
||||||
|
file_path = safe_join(self.edit_upload_dir, stored_name)
|
||||||
|
if file_path is None:
|
||||||
|
return jsonify({"error": "Invalid file path"}), 400
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
session.attachments[name] = EditSessionFile(
|
||||||
|
file_id=attachment_id,
|
||||||
|
file_path=file_path,
|
||||||
|
file_name=original_name,
|
||||||
|
file_type=file_type,
|
||||||
|
)
|
||||||
|
return jsonify({"name": name, "file_name": original_name})
|
||||||
|
|
||||||
|
def handle_message(self, session_id: str, payload: models.EditMessageRequest) -> ResponseReturnValue:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return jsonify({"error": "Edit session not found"}), 404
|
||||||
|
|
||||||
|
user_message = payload.message.strip()
|
||||||
|
if not user_message:
|
||||||
|
return jsonify({"error": "Message is required"}), 400
|
||||||
|
|
||||||
|
if payload.action == "status":
|
||||||
|
return self._build_status_response(session)
|
||||||
|
|
||||||
|
if payload.action in {"confirm", "cancel"} and not session.pending_plan:
|
||||||
|
response = models.EditMessageResponse(assistant_message="")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
# Add user message to history
|
||||||
|
session.messages.append(models.ChatMessage(role="user", content=user_message))
|
||||||
|
|
||||||
|
# NEW: Use state router for pending plan handling
|
||||||
|
if session.pending_plan:
|
||||||
|
routing_result = route_message(
|
||||||
|
session,
|
||||||
|
user_message,
|
||||||
|
self._strip_file_context_history(session.messages),
|
||||||
|
)
|
||||||
|
|
||||||
|
if routing_result.action == "execute":
|
||||||
|
if routing_result.plan is None:
|
||||||
|
assistant_message = "The pending plan could not be found. Please try again."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 500
|
||||||
|
# Consume the plan immediately to avoid duplicate confirm requests
|
||||||
|
session.pending_plan = None
|
||||||
|
# Execute the pending plan
|
||||||
|
return self._execute_pending_plan(session, routing_result.plan)
|
||||||
|
|
||||||
|
elif routing_result.action == "cancelled":
|
||||||
|
assistant_message = routing_result.message or "Cancelled. Let me know if you want to do something else."
|
||||||
|
# Consume the plan immediately to avoid duplicate cancel requests
|
||||||
|
session.pending_plan = None
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
elif routing_result.action == "answer_question":
|
||||||
|
assistant_message = routing_result.message or "Please confirm to proceed or cancel to stop."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
elif routing_result.action == "already_executed":
|
||||||
|
assistant_message = routing_result.message or "This plan has already been executed."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
elif routing_result.action == "route_fresh":
|
||||||
|
# Clear pending and continue to fresh request handling below
|
||||||
|
session.pending_plan = None
|
||||||
|
|
||||||
|
elif routing_result.action == "error":
|
||||||
|
assistant_message = routing_result.error or "Something went wrong. Please try again."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 500
|
||||||
|
|
||||||
|
# Repeat request handling - use atomic execution for consistency
|
||||||
|
if session.last_operation_id and self._is_repeat_request(user_message):
|
||||||
|
operation_id = session.last_operation_id
|
||||||
|
param_model = self.tool_catalog.get_operation(operation_id)
|
||||||
|
if not param_model:
|
||||||
|
session.last_operation_id = None
|
||||||
|
session.last_parameters = None
|
||||||
|
else:
|
||||||
|
parameters = apply_smart_defaults(
|
||||||
|
user_message,
|
||||||
|
session.last_parameters or param_model.model_validate({}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plan and execute atomically (same as new requests)
|
||||||
|
plan = PendingPlan(
|
||||||
|
state="AWAITING_CONFIRM",
|
||||||
|
ops=[PendingOperation(operation_id=operation_id, parameters=parameters)],
|
||||||
|
risk_level="low",
|
||||||
|
risk_reasons=[],
|
||||||
|
source_message=user_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._execute_pending_plan(session, plan)
|
||||||
|
|
||||||
|
intent = payload.edit_intent
|
||||||
|
if not intent:
|
||||||
|
intent = classify_edit_intent(
|
||||||
|
user_message,
|
||||||
|
self._strip_file_context_history(session.messages),
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
if intent and intent.mode == "document_question":
|
||||||
|
primary = self._primary_file(session)
|
||||||
|
if not primary:
|
||||||
|
assistant_message = "I couldn't find a file in this session. Please upload a PDF first."
|
||||||
|
elif primary.preflight.has_text_layer is False:
|
||||||
|
assistant_message = "I couldn't read text in this PDF. Want me to run OCR first?"
|
||||||
|
else:
|
||||||
|
self._ensure_file_context(session)
|
||||||
|
assistant_message = answer_pdf_question(primary.file_path, user_message)
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
if intent and intent.mode in {"info", "ambiguous"}:
|
||||||
|
if intent.mode == "info":
|
||||||
|
if intent.requires_file_context:
|
||||||
|
self._ensure_file_context(session)
|
||||||
|
primary = self._primary_file(session)
|
||||||
|
if primary:
|
||||||
|
assistant_message = answer_pdf_question(primary.file_path, user_message)
|
||||||
|
else:
|
||||||
|
assistant_message = "I couldn't find a file in this session. Please upload a PDF first."
|
||||||
|
else:
|
||||||
|
assistant_message = answer_edit_info(
|
||||||
|
user_message,
|
||||||
|
self._strip_file_context_history(session.messages),
|
||||||
|
session.file_name,
|
||||||
|
session.file_type,
|
||||||
|
self.tool_catalog,
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assistant_message = "Do you want me to run a tool on this file, or just explain the options?"
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message, needs_more_info=True)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
if intent and intent.requires_file_context:
|
||||||
|
self._ensure_file_context(session)
|
||||||
|
|
||||||
|
selection_history = (
|
||||||
|
session.messages
|
||||||
|
if intent and intent.requires_file_context
|
||||||
|
else self._strip_file_context_history(session.messages)
|
||||||
|
)
|
||||||
|
selection = self.tool_catalog.select_edit_tool(
|
||||||
|
history=selection_history,
|
||||||
|
uploaded_files=[
|
||||||
|
models.UploadedFileInfo(name=item.file_name, type=item.file_type) for item in session.files
|
||||||
|
],
|
||||||
|
preflight=session.preflight,
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[EDIT] selection action=%s operation_ids=%s",
|
||||||
|
selection.action,
|
||||||
|
selection.operation_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_ops = self._selection_operations(session, selection, user_message, selection_history)
|
||||||
|
analytics.track_event(
|
||||||
|
user_id=session.session_id,
|
||||||
|
event_name="edit_tool_selected",
|
||||||
|
properties={
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"selection_action": selection.action,
|
||||||
|
"operation_ids": [op_id for op_id, _ in selected_ops],
|
||||||
|
"operation_count": len(selected_ops),
|
||||||
|
"intent_mode": intent.mode if intent else None,
|
||||||
|
"has_file_context": bool(intent and intent.requires_file_context),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if selection.action == "call_tool" and not selected_ops:
|
||||||
|
logger.warning(
|
||||||
|
"[EDIT] selection has no operations session_id=%s message=%s payload=%s",
|
||||||
|
session.session_id,
|
||||||
|
user_message,
|
||||||
|
json.dumps(selection.model_dump(), ensure_ascii=True),
|
||||||
|
)
|
||||||
|
assistant_message = format_disambiguation_question()
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(
|
||||||
|
assistant_message=assistant_message,
|
||||||
|
needs_more_info=True,
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
logger.info(
|
||||||
|
"[EDIT] selected_ops session_id=%s count=%s ops=%s",
|
||||||
|
session.session_id,
|
||||||
|
len(selected_ops),
|
||||||
|
[op_id for op_id, _ in selected_ops],
|
||||||
|
)
|
||||||
|
|
||||||
|
if selection.action == "ask_user":
|
||||||
|
if not selected_ops:
|
||||||
|
assistant_message = selection.response_message or "I could not find a matching tool for that request."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message, needs_more_info=True)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
# Use _handle_selected_ops for consistency - it handles missing params and PendingPlan creation
|
||||||
|
return self._handle_selected_ops(
|
||||||
|
selected_ops,
|
||||||
|
user_message=user_message,
|
||||||
|
session=session,
|
||||||
|
response_message=selection.response_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selection.action != "call_tool" or not selected_ops:
|
||||||
|
assistant_message = selection.response_message or "I could not find a matching tool for that request."
|
||||||
|
logger.info(
|
||||||
|
"[EDIT] no_tool/no_ops action=%s message=%s",
|
||||||
|
selection.action,
|
||||||
|
assistant_message[:100] if assistant_message else None,
|
||||||
|
)
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
return self._handle_selected_ops(
|
||||||
|
selected_ops,
|
||||||
|
user_message=user_message,
|
||||||
|
session=session,
|
||||||
|
response_message=selection.response_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_pending_plan(self, session: EditSession, plan: PendingPlan) -> ResponseReturnValue:
|
||||||
|
"""
|
||||||
|
Convert pending plan into frontend-executable tool calls.
|
||||||
|
Marks plan as executed for idempotency.
|
||||||
|
"""
|
||||||
|
# Check idempotency
|
||||||
|
if plan.plan_id in session.executed_plan_ids:
|
||||||
|
assistant_message = "This operation has already been executed."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
tool_calls: list[models.EditToolCall] = []
|
||||||
|
for pending_op in plan.ops:
|
||||||
|
param_model = self.tool_catalog.get_operation(pending_op.operation_id)
|
||||||
|
if not param_model:
|
||||||
|
continue
|
||||||
|
tool_calls.append(
|
||||||
|
models.EditToolCall(
|
||||||
|
operation_id=pending_op.operation_id,
|
||||||
|
parameters=pending_op.parameters,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
assistant_message = "I could not build a runnable tool plan. Please try rephrasing the request."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 500
|
||||||
|
|
||||||
|
session.executed_plan_ids.add(plan.plan_id)
|
||||||
|
session.pending_plan = None
|
||||||
|
|
||||||
|
if plan.ops:
|
||||||
|
session.last_operation_id = plan.ops[-1].operation_id
|
||||||
|
session.last_parameters = plan.ops[-1].parameters
|
||||||
|
|
||||||
|
execution_mode = "single" if len(tool_calls) == 1 else "pipeline"
|
||||||
|
pipeline_name = "AI Generated Pipeline" if execution_mode == "pipeline" else None
|
||||||
|
|
||||||
|
analytics.track_event(
|
||||||
|
user_id=session.session_id,
|
||||||
|
event_name="edit_plan_emitted_for_frontend_execution",
|
||||||
|
properties={
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"operation_ids": [op.operation_id for op in plan.ops],
|
||||||
|
"operation_count": len(plan.ops),
|
||||||
|
"risk_level": plan.risk_level,
|
||||||
|
"risk_reasons_count": len(plan.risk_reasons),
|
||||||
|
"execution_mode": execution_mode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content="Prepared tool plan for frontend"))
|
||||||
|
|
||||||
|
response = models.EditMessageResponse(
|
||||||
|
assistant_message="",
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
execute_on_frontend=True,
|
||||||
|
frontend_plan=models.FrontendExecutionPlan(
|
||||||
|
mode=execution_mode,
|
||||||
|
steps=[
|
||||||
|
models.FrontendExecutionStep(
|
||||||
|
operation_id=call.operation_id,
|
||||||
|
parameters=call.parameters,
|
||||||
|
)
|
||||||
|
for call in tool_calls
|
||||||
|
],
|
||||||
|
pipeline_name=pipeline_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
def _selection_operations(
|
||||||
|
self,
|
||||||
|
session: EditSession,
|
||||||
|
selection: models.EditToolSelection,
|
||||||
|
user_message: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
) -> list[tuple[models.tool_models.OperationId, models.tool_models.ParamToolModel | None]]:
|
||||||
|
ops: list[tuple[models.tool_models.OperationId, models.tool_models.ParamToolModel | None]] = []
|
||||||
|
for operation_id in selection.operation_ids:
|
||||||
|
param_model = self.tool_catalog.get_operation(operation_id)
|
||||||
|
if not param_model:
|
||||||
|
continue
|
||||||
|
params = self.tool_catalog.extract_operation_parameters(
|
||||||
|
operation_id=operation_id,
|
||||||
|
previous_operations=ops,
|
||||||
|
user_message=user_message,
|
||||||
|
history=history,
|
||||||
|
preflight=session.preflight,
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
ops.append((operation_id, params))
|
||||||
|
return ops
|
||||||
|
|
||||||
|
def _is_repeat_request(self, message: str) -> bool:
|
||||||
|
value = message.strip().lower()
|
||||||
|
|
||||||
|
# Don't treat as repeat if user is requesting new/additional actions
|
||||||
|
# E.g., "compress and rotate again", "make it smaller, and rotate again"
|
||||||
|
action_words = [
|
||||||
|
"compress",
|
||||||
|
"optimize",
|
||||||
|
"smaller",
|
||||||
|
"larger",
|
||||||
|
"bigger",
|
||||||
|
"rotate",
|
||||||
|
"split",
|
||||||
|
"merge",
|
||||||
|
"delete",
|
||||||
|
"extract",
|
||||||
|
"add",
|
||||||
|
"remove",
|
||||||
|
"convert",
|
||||||
|
"repair",
|
||||||
|
"unlock",
|
||||||
|
"watermark",
|
||||||
|
"sign",
|
||||||
|
"flatten",
|
||||||
|
"ocr",
|
||||||
|
"searchable",
|
||||||
|
"linearize",
|
||||||
|
"grayscale",
|
||||||
|
]
|
||||||
|
if any(action in value for action in action_words):
|
||||||
|
# If message contains action words, parse it as a new request, not a repeat
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Only treat as repeat if it's JUST asking to repeat with no new actions
|
||||||
|
return any(
|
||||||
|
phrase in value
|
||||||
|
for phrase in (
|
||||||
|
"do that again",
|
||||||
|
"do it again",
|
||||||
|
"repeat that",
|
||||||
|
"repeat it",
|
||||||
|
"redo that",
|
||||||
|
"redo it",
|
||||||
|
"same again",
|
||||||
|
"run again",
|
||||||
|
"try again",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_selected_ops(
|
||||||
|
self,
|
||||||
|
selected_ops: list[tuple[models.tool_models.OperationId, models.tool_models.ParamToolModel | None]],
|
||||||
|
user_message: str,
|
||||||
|
session: EditSession,
|
||||||
|
*,
|
||||||
|
response_message: str | None = None,
|
||||||
|
) -> ResponseReturnValue:
|
||||||
|
"""
|
||||||
|
Handle selected operations using execution and parameter completion.
|
||||||
|
Creates PendingPlan and routes through state machine.
|
||||||
|
"""
|
||||||
|
# Refresh preflight for up-to-date metadata (only for PDFs)
|
||||||
|
if session.files and session.files[0].file_path:
|
||||||
|
if session.files[0].file_type == "application/pdf":
|
||||||
|
session.preflight = get_pdf_preflight(session.files[0].file_path)
|
||||||
|
else:
|
||||||
|
session.preflight = models.PdfPreflight()
|
||||||
|
|
||||||
|
# Process each operation (first pass without forcing defaults)
|
||||||
|
pending_ops: list[PendingOperation] = []
|
||||||
|
operations: list[models.tool_models.OperationId] = []
|
||||||
|
|
||||||
|
for operation_id, raw_parameters in selected_ops:
|
||||||
|
param_model = self.tool_catalog.get_operation(operation_id)
|
||||||
|
if not param_model:
|
||||||
|
assistant_message = "I could not find that tool. Please try another request."
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=assistant_message))
|
||||||
|
response = models.EditMessageResponse(assistant_message=assistant_message)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
operations.append(operation_id)
|
||||||
|
|
||||||
|
# Apply defaults
|
||||||
|
parameters = apply_smart_defaults(
|
||||||
|
user_message,
|
||||||
|
raw_parameters or param_model.model_validate({}),
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_ops.append(PendingOperation(operation_id=operation_id, parameters=parameters))
|
||||||
|
|
||||||
|
# Validate operation chain compatibility
|
||||||
|
validation = validate_operation_chain(operations)
|
||||||
|
if not validation.is_valid:
|
||||||
|
error_msg = validation.error_message or "Incompatible operation chain"
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=error_msg))
|
||||||
|
# Return structured data for frontend to format with translated names
|
||||||
|
response = models.EditMessageResponse(
|
||||||
|
assistant_message="", # Frontend will format from validation_error
|
||||||
|
result_json=(
|
||||||
|
{"validation_error": validation.error_data.model_dump(by_alias=True, mode="json")}
|
||||||
|
if validation.error_data
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||||
|
|
||||||
|
# No missing params - assess risk
|
||||||
|
risk_assessment = assess_plan_risk(operations, session.preflight)
|
||||||
|
|
||||||
|
# Create pending plan or execute immediately
|
||||||
|
if risk_assessment.get("should_confirm"):
|
||||||
|
# Need confirmation - create AWAITING_CONFIRM plan
|
||||||
|
plan = PendingPlan(
|
||||||
|
state="AWAITING_CONFIRM",
|
||||||
|
ops=pending_ops,
|
||||||
|
risk_level=risk_assessment["level"],
|
||||||
|
risk_reasons=risk_assessment.get("reasons", []),
|
||||||
|
source_message=user_message,
|
||||||
|
)
|
||||||
|
session.pending_plan = plan
|
||||||
|
|
||||||
|
plan_summary = build_plan_summary(operations)
|
||||||
|
plan_summary += "\n\nConfirm to proceed or cancel to stop."
|
||||||
|
|
||||||
|
session.messages.append(models.ChatMessage(role="assistant", content=plan_summary))
|
||||||
|
|
||||||
|
# Build tool calls for preview
|
||||||
|
tool_calls = [
|
||||||
|
models.EditToolCall(
|
||||||
|
operation_id=op.operation_id,
|
||||||
|
parameters=op.parameters,
|
||||||
|
)
|
||||||
|
for op in pending_ops
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get warning if high risk
|
||||||
|
warning = None
|
||||||
|
if len(operations) == 1:
|
||||||
|
op_risk = get_operation_risk(operations[0], session.preflight)
|
||||||
|
warning = op_risk.get("warning")
|
||||||
|
|
||||||
|
response = models.EditMessageResponse(
|
||||||
|
assistant_message=plan_summary,
|
||||||
|
confirmation_required=True,
|
||||||
|
warning=warning,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
)
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
|
||||||
|
# Low risk - execute immediately using atomic execution
|
||||||
|
plan = PendingPlan(
|
||||||
|
state="AWAITING_CONFIRM", # Use confirm state but execute immediately
|
||||||
|
ops=pending_ops,
|
||||||
|
risk_level=risk_assessment["level"],
|
||||||
|
risk_reasons=risk_assessment.get("reasons", []),
|
||||||
|
source_message=user_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._execute_pending_plan(session, plan)
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
from config import SMART_MODEL
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import ChatMessage, IncompatibleChainError, OperationRef, PdfAnswer, PdfPreflight, tool_models
|
||||||
|
from pdf_text_editor import convert_pdf_to_text_editor_document
|
||||||
|
from prompts import pdf_qa_system_prompt
|
||||||
|
|
||||||
|
from .session_store import EditSessionFile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "_", filename or "")
|
||||||
|
return cleaned.strip("._") or "upload.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def infer_smart_defaults(
|
||||||
|
user_message: str,
|
||||||
|
parameters: tool_models.ParamToolModel,
|
||||||
|
) -> tool_models.ParamToolModel:
|
||||||
|
# TODO: Get rid of this function. It only works in English and shouldn't be necessary
|
||||||
|
params = parameters.model_copy()
|
||||||
|
text = user_message.lower()
|
||||||
|
|
||||||
|
if isinstance(params, tool_models.RotateParams):
|
||||||
|
desired_angle = 90
|
||||||
|
if any(word in text for word in ["right", "clockwise"]):
|
||||||
|
desired_angle = 90
|
||||||
|
elif any(word in text for word in ["left", "counter", "anticlockwise", "anti-clockwise"]):
|
||||||
|
desired_angle = 270
|
||||||
|
elif any(word in text for word in ["upside", "180"]):
|
||||||
|
desired_angle = 180
|
||||||
|
if params.angle not in {90, 180, 270}:
|
||||||
|
params.angle = desired_angle
|
||||||
|
return params
|
||||||
|
|
||||||
|
if isinstance(params, tool_models.OcrParams):
|
||||||
|
if any(word in text for word in ["searchable", "text layer", "text overlaid"]):
|
||||||
|
params.ocr_render_type = "sandwich"
|
||||||
|
elif any(word in text for word in ["hocr", "layout", "bounding boxes"]):
|
||||||
|
params.ocr_render_type = "hocr"
|
||||||
|
if any(word in text for word in ["spanish", "español", "espanol"]):
|
||||||
|
params.languages = ["spa"]
|
||||||
|
return params
|
||||||
|
|
||||||
|
if isinstance(params, tool_models.WatermarkParams):
|
||||||
|
if params.watermark_type is None:
|
||||||
|
has_text = bool(params.watermark_text)
|
||||||
|
has_image = params.watermark_image is not None
|
||||||
|
if has_image and not has_text:
|
||||||
|
params.watermark_type = "image"
|
||||||
|
else:
|
||||||
|
params.watermark_type = "text"
|
||||||
|
return params
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def format_disambiguation_question() -> str:
|
||||||
|
return (
|
||||||
|
"I can help with rotate, OCR (make searchable), compress, split, merge, extract, and more. "
|
||||||
|
"Which change do you want?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Operations that must be last in a chain — either because they produce non-PDF output,
|
||||||
|
# or because their output (e.g. an encrypted PDF) cannot be processed by subsequent operations.
|
||||||
|
TERMINAL_OPERATIONS = {
|
||||||
|
# Conversion operations (produce various file formats)
|
||||||
|
"pdfToCsv", # Produces CSV
|
||||||
|
"pdfToExcel", # Produces Excel
|
||||||
|
"pdfToHtml", # Produces HTML
|
||||||
|
"pdfToXml", # Produces XML
|
||||||
|
"pdfToText", # Produces plain text
|
||||||
|
"processPdfToRTForTXT", # Produces RTF/TXT
|
||||||
|
"convertPdfToCbr", # Produces CBR
|
||||||
|
"convertPdfToCbz", # Produces CBZ
|
||||||
|
# Analysis operations (produce JSON/Boolean responses)
|
||||||
|
"containsImage", # Returns Boolean
|
||||||
|
"containsText", # Returns Boolean
|
||||||
|
"getPdfInfo", # Returns JSON
|
||||||
|
"getBasicInfo", # Returns JSON
|
||||||
|
"getDocumentProperties", # Returns JSON
|
||||||
|
"getAnnotationInfo", # Returns JSON
|
||||||
|
"getFontInfo", # Returns JSON
|
||||||
|
"getFormFields", # Returns JSON
|
||||||
|
"getPageCount", # Returns JSON
|
||||||
|
"getPageDimensions", # Returns JSON
|
||||||
|
"getSecurityInfo", # Returns JSON
|
||||||
|
"pageCount", # Returns JSON
|
||||||
|
"pageRotation", # Returns JSON
|
||||||
|
"pageSize", # Returns JSON
|
||||||
|
"fileSize", # Returns JSON
|
||||||
|
"validateSignature", # Returns JSON
|
||||||
|
# Security — produces encrypted PDF that cannot be processed by subsequent operations
|
||||||
|
"addPassword",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidationResult:
|
||||||
|
"""Result of operation chain validation."""
|
||||||
|
|
||||||
|
is_valid: bool
|
||||||
|
error_message: str | None = None
|
||||||
|
error_data: IncompatibleChainError | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_operation_chain(operations: list[tool_models.OperationId]) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Validate that operation chain is compatible (output of N can be input to N+1).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult with is_valid, error_message, and error_data.
|
||||||
|
error_data contains structured info for frontend formatting with translated names.
|
||||||
|
"""
|
||||||
|
if len(operations) <= 1:
|
||||||
|
return ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
for i, operation_id in enumerate(operations[:-1]): # Check all except last
|
||||||
|
if operation_id in TERMINAL_OPERATIONS:
|
||||||
|
next_op_id = operations[i + 1]
|
||||||
|
# Return structured data for frontend to format with translated names
|
||||||
|
# Include path/method so frontend can use getToolFromToolCall() for lookup
|
||||||
|
error_data = IncompatibleChainError(
|
||||||
|
type="incompatible_chain",
|
||||||
|
current_operation=OperationRef(
|
||||||
|
operation_id=operation_id,
|
||||||
|
),
|
||||||
|
next_operation=OperationRef(
|
||||||
|
operation_id=next_op_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Fallback message using summaries (in case frontend doesn't handle it)
|
||||||
|
current_name = operation_id
|
||||||
|
next_name = next_op_id
|
||||||
|
error_message = (
|
||||||
|
f"Cannot chain '{current_name}' with '{next_name}'. "
|
||||||
|
f"'{current_name}' must be the last operation in a chain. "
|
||||||
|
f"Please run '{current_name}' as the final operation, or remove it from the chain."
|
||||||
|
)
|
||||||
|
return ValidationResult(
|
||||||
|
is_valid=False,
|
||||||
|
error_message=error_message,
|
||||||
|
error_data=error_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan_summary(ops: list[tool_models.OperationId]) -> str:
|
||||||
|
if not ops:
|
||||||
|
return "I will run the requested tools."
|
||||||
|
if len(ops) == 1:
|
||||||
|
return f"I will run {ops[0]}."
|
||||||
|
return "I will run " + ", then ".join(ops) + "."
|
||||||
|
|
||||||
|
|
||||||
|
def get_pdf_preflight(file_path: str) -> PdfPreflight:
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
reader = PdfReader(file_path)
|
||||||
|
|
||||||
|
is_encrypted = bool(reader.is_encrypted)
|
||||||
|
if reader.is_encrypted:
|
||||||
|
reader.decrypt("")
|
||||||
|
page_count = len(reader.pages)
|
||||||
|
text_found = False
|
||||||
|
for page in reader.pages[:2]:
|
||||||
|
extracted = page.extract_text()
|
||||||
|
if len(extracted.strip()) > 20:
|
||||||
|
text_found = True
|
||||||
|
break
|
||||||
|
return PdfPreflight(
|
||||||
|
file_size_mb=round(file_size / (1024 * 1024), 2),
|
||||||
|
is_encrypted=is_encrypted,
|
||||||
|
page_count=page_count,
|
||||||
|
has_text_layer=text_found,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_file(
|
||||||
|
file_path: str,
|
||||||
|
file_name: str,
|
||||||
|
content_type: str | None,
|
||||||
|
content_disposition: str | None = None,
|
||||||
|
) -> EditSessionFile:
|
||||||
|
"""
|
||||||
|
Create an EditSessionFile with proper type detection and preflight handling.
|
||||||
|
|
||||||
|
Only runs PDF preflight for actual PDF files. For non-PDF files, uses empty preflight dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file on disk
|
||||||
|
file_name: Default filename to use if not in content_disposition
|
||||||
|
content_type: MIME type from response (None defaults to application/octet-stream)
|
||||||
|
content_disposition: Content-Disposition header for filename extraction
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EditSessionFile with proper file_type and preflight data
|
||||||
|
"""
|
||||||
|
# Normalize content type (avoid defaulting to PDF)
|
||||||
|
normalized_content_type = content_type or "application/octet-stream"
|
||||||
|
file_type = normalized_content_type.split(";")[0].strip()
|
||||||
|
|
||||||
|
# Extract filename from content_disposition if available
|
||||||
|
derived_name = file_name
|
||||||
|
if content_disposition and "filename=" in content_disposition:
|
||||||
|
derived_name = content_disposition.split("filename=")[-1].strip('"')
|
||||||
|
|
||||||
|
# Only get PDF preflight for actual PDF files
|
||||||
|
preflight = get_pdf_preflight(file_path) if file_type == "application/pdf" else PdfPreflight()
|
||||||
|
|
||||||
|
return EditSessionFile(
|
||||||
|
file_id=uuid.uuid4().hex,
|
||||||
|
file_path=file_path,
|
||||||
|
file_name=derived_name,
|
||||||
|
file_type=file_type,
|
||||||
|
preflight=preflight,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_pdf_text_context(
|
||||||
|
file_path: str,
|
||||||
|
*,
|
||||||
|
max_pages: int = 12,
|
||||||
|
max_chars_per_page: int = 600,
|
||||||
|
max_total_chars: int = 4000,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
doc = convert_pdf_to_text_editor_document(file_path)
|
||||||
|
pages = doc.document.pages if doc else []
|
||||||
|
context_pages: list[dict[str, Any]] = []
|
||||||
|
total_chars = 0
|
||||||
|
for index, page in enumerate(pages[:max_pages]):
|
||||||
|
text_chunks = []
|
||||||
|
for elem in page.text_elements:
|
||||||
|
if elem.text:
|
||||||
|
text_chunks.append(str(elem.text))
|
||||||
|
combined = " ".join(text_chunks)
|
||||||
|
combined = " ".join(combined.split())
|
||||||
|
if not combined:
|
||||||
|
continue
|
||||||
|
snippet = combined[:max_chars_per_page]
|
||||||
|
total_chars += len(snippet)
|
||||||
|
if total_chars > max_total_chars:
|
||||||
|
break
|
||||||
|
context_pages.append({"page": index + 1, "text": snippet})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "file_context",
|
||||||
|
"page_count": len(pages),
|
||||||
|
"pages": context_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def answer_pdf_question(file_path: str, question: str) -> str:
|
||||||
|
doc = convert_pdf_to_text_editor_document(file_path)
|
||||||
|
pages = doc.document.pages if doc else []
|
||||||
|
snippets: list[str] = []
|
||||||
|
for page in pages:
|
||||||
|
for elem in page.text_elements:
|
||||||
|
text = elem.text
|
||||||
|
if text:
|
||||||
|
snippets.append(str(text))
|
||||||
|
if not snippets:
|
||||||
|
raise RuntimeError("No readable text found in PDF.")
|
||||||
|
|
||||||
|
context = " ".join(snippets)
|
||||||
|
context = " ".join(context.split())
|
||||||
|
max_context = 10000
|
||||||
|
if len(context) > max_context:
|
||||||
|
context = context[:max_context]
|
||||||
|
|
||||||
|
system_prompt = pdf_qa_system_prompt() + "\nReturn JSON matching the provided schema."
|
||||||
|
user_prompt = f"Question: {question}\n\nPDF text:\n{context}"
|
||||||
|
messages = [
|
||||||
|
ChatMessage(role="system", content=system_prompt),
|
||||||
|
ChatMessage(role="user", content=user_prompt),
|
||||||
|
]
|
||||||
|
response = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
PdfAnswer,
|
||||||
|
tag="edit_pdf_answer",
|
||||||
|
max_tokens=500,
|
||||||
|
)
|
||||||
|
answer = response.answer.strip()
|
||||||
|
normalized_answer = re.sub(r"\s+", " ", answer).strip().lower()
|
||||||
|
normalized_context = re.sub(r"\s+", " ", context).strip().lower()
|
||||||
|
copied_context = bool(normalized_answer) and normalized_answer in normalized_context
|
||||||
|
if copied_context:
|
||||||
|
raise RuntimeError("AI answer echoed the source text.")
|
||||||
|
return answer
|
||||||
|
|
||||||
|
|
||||||
|
def apply_smart_defaults(
|
||||||
|
message: str,
|
||||||
|
parameters: tool_models.ParamToolModel,
|
||||||
|
) -> tool_models.ParamToolModel:
|
||||||
|
return infer_smart_defaults(message, parameters)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from models import tool_models
|
||||||
|
|
||||||
|
|
||||||
|
def dump_params(params: tool_models.ParamToolModel | None) -> dict[str, object]:
|
||||||
|
if params is None:
|
||||||
|
return {}
|
||||||
|
return params.model_dump(by_alias=True, exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_param_keys(
|
||||||
|
param_model: tool_models.ParamToolModelType | None,
|
||||||
|
data: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if param_model is None or not data:
|
||||||
|
return data
|
||||||
|
field_map: dict[str, str] = {}
|
||||||
|
for name, field in param_model.model_fields.items():
|
||||||
|
alias = field.alias or name
|
||||||
|
field_map[name.lower()] = alias
|
||||||
|
field_map[alias.lower()] = alias
|
||||||
|
|
||||||
|
normalized: dict[str, object] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
mapped = field_map.get(key.lower())
|
||||||
|
normalized[mapped or key] = value
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def merge_param_updates(
|
||||||
|
param_model: tool_models.ParamToolModelType | None,
|
||||||
|
base: tool_models.ParamToolModel | None,
|
||||||
|
updates: dict[str, object],
|
||||||
|
) -> tool_models.ParamToolModel | None:
|
||||||
|
if param_model is None:
|
||||||
|
return None
|
||||||
|
data = dump_params(base)
|
||||||
|
data.update(updates)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
normalized = normalize_param_keys(param_model, data)
|
||||||
|
return param_model.model_validate(normalized)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from werkzeug.security import safe_join
|
||||||
|
|
||||||
|
from config import OUTPUT_DIR
|
||||||
|
from file_processing_agent import ToolCatalogService
|
||||||
|
from llm_utils import AIProviderOverloadedError
|
||||||
|
from models import EditMessageRequest, PdfEditorUploadResponse
|
||||||
|
from pdf_text_editor import convert_pdf_to_text_editor_document
|
||||||
|
|
||||||
|
from .service import EditService
|
||||||
|
from .session_store import EditSessionStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
edit_blueprint = Blueprint("edit", __name__)
|
||||||
|
_edit_service = EditService(EditSessionStore(), ToolCatalogService())
|
||||||
|
|
||||||
|
|
||||||
|
def register_edit_routes(app) -> None:
|
||||||
|
app.register_blueprint(edit_blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body(model):
|
||||||
|
return model.model_validate(request.get_json(silent=True) or {})
|
||||||
|
|
||||||
|
|
||||||
|
@edit_blueprint.route("/api/edit/sessions", methods=["POST"])
|
||||||
|
def create_edit_session():
|
||||||
|
files = request.files.getlist("file")
|
||||||
|
return _edit_service.create_session(files)
|
||||||
|
|
||||||
|
|
||||||
|
@edit_blueprint.route("/api/edit/sessions/<session_id>/messages", methods=["POST"])
|
||||||
|
def edit_session_message(session_id: str):
|
||||||
|
payload = _json_body(EditMessageRequest)
|
||||||
|
try:
|
||||||
|
return _edit_service.handle_message(session_id, payload)
|
||||||
|
except AIProviderOverloadedError as exc:
|
||||||
|
logger.warning("[EDIT] AI provider overloaded session_id=%s (exc=%s)", session_id, exc)
|
||||||
|
response = {
|
||||||
|
"assistantMessage": "The AI service is temporarily unavailable. Please try again later.",
|
||||||
|
"needsMoreInfo": True,
|
||||||
|
}
|
||||||
|
return jsonify(response), 503
|
||||||
|
|
||||||
|
|
||||||
|
@edit_blueprint.route("/api/edit/sessions/<session_id>/attachments", methods=["POST"])
|
||||||
|
def edit_session_attachment(session_id: str):
|
||||||
|
name = request.form.get("name")
|
||||||
|
file = request.files.get("file")
|
||||||
|
return _edit_service.add_attachment(session_id, name, file)
|
||||||
|
|
||||||
|
|
||||||
|
@edit_blueprint.route("/api/pdf-editor/document", methods=["GET"])
|
||||||
|
def pdf_editor_document():
|
||||||
|
"""Expose a JSON snapshot of the PDF for rich text editing."""
|
||||||
|
pdf_url = request.args.get("pdfUrl")
|
||||||
|
if not pdf_url:
|
||||||
|
return jsonify({"error": "Missing pdfUrl"}), 400
|
||||||
|
|
||||||
|
filename = os.path.basename(pdf_url.split("?")[0])
|
||||||
|
if not filename:
|
||||||
|
return jsonify({"error": "Invalid pdf file"}), 400
|
||||||
|
if not filename.lower().endswith(".pdf"):
|
||||||
|
return jsonify({"error": "Invalid pdf file"}), 400
|
||||||
|
|
||||||
|
pdf_path = safe_join(OUTPUT_DIR, filename)
|
||||||
|
if pdf_path is None or not os.path.exists(pdf_path):
|
||||||
|
return jsonify({"error": "PDF not found"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = convert_pdf_to_text_editor_document(pdf_path)
|
||||||
|
return jsonify(document.model_dump(by_alias=True, exclude_none=True))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({"error": "Conversion failed"}), 500
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
logger.error("[PDF-EDITOR] Conversion failed: %s", exc)
|
||||||
|
return jsonify({"error": "Conversion failed"}), 500
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[PDF-EDITOR] Unexpected conversion failure: %s", exc)
|
||||||
|
return jsonify({"error": "Conversion failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@edit_blueprint.route("/api/pdf-editor/upload", methods=["POST"])
|
||||||
|
def pdf_editor_upload():
|
||||||
|
"""Accept an edited PDF and save it so the preview can refresh."""
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file:
|
||||||
|
return jsonify({"error": "Missing file"}), 400
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
filename = f"{job_id}-edited.pdf"
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
file.save(output_path)
|
||||||
|
|
||||||
|
logger.info("[PDF-EDITOR] uploaded edited PDF job_id=%s -> %s", job_id, filename)
|
||||||
|
response = PdfEditorUploadResponse(pdf_url=f"/output/{filename}")
|
||||||
|
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .handlers import EditService
|
||||||
|
|
||||||
|
__all__ = ["EditService"]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from models import ChatMessage, PdfPreflight, tool_models
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EditSessionFile:
|
||||||
|
file_id: str
|
||||||
|
file_path: str
|
||||||
|
file_name: str
|
||||||
|
file_type: str | None
|
||||||
|
preflight: PdfPreflight = field(default_factory=PdfPreflight)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingOperation:
|
||||||
|
"""Single operation in a pending plan."""
|
||||||
|
|
||||||
|
operation_id: tool_models.OperationId
|
||||||
|
parameters: tool_models.ParamToolModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingPlan:
|
||||||
|
"""
|
||||||
|
Unified pending plan for both awaiting params and awaiting confirmation.
|
||||||
|
This replaces the old separate pending_operations/pending_operation_id/pending_requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
plan_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
state: Literal["AWAITING_CONFIRM"] = "AWAITING_CONFIRM"
|
||||||
|
ops: list[PendingOperation] = field(default_factory=list)
|
||||||
|
risk_level: str = "low"
|
||||||
|
risk_reasons: list[str] = field(default_factory=list)
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
source_message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EditSession:
|
||||||
|
session_id: str
|
||||||
|
file_path: str
|
||||||
|
file_name: str
|
||||||
|
file_type: str | None
|
||||||
|
messages: list[ChatMessage] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Unified pending plan
|
||||||
|
pending_plan: PendingPlan | None = None
|
||||||
|
|
||||||
|
# Last executed operation (for repeat requests)
|
||||||
|
last_operation_id: tool_models.OperationId | None = None
|
||||||
|
last_parameters: tool_models.ParamToolModel | None = None
|
||||||
|
|
||||||
|
# File metadata
|
||||||
|
preflight: PdfPreflight = field(default_factory=PdfPreflight)
|
||||||
|
files: list[EditSessionFile] = field(default_factory=list)
|
||||||
|
attachments: dict[str, EditSessionFile] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Document context (for Q&A)
|
||||||
|
file_context: dict[str, Any] | None = None
|
||||||
|
file_context_path: str | None = None
|
||||||
|
|
||||||
|
# Idempotency tracking
|
||||||
|
executed_plan_ids: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
class EditSessionStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._sessions: dict[str, EditSession] = {}
|
||||||
|
|
||||||
|
def get(self, session_id: str) -> EditSession | None:
|
||||||
|
return self._sessions.get(session_id)
|
||||||
|
|
||||||
|
def set(self, session: EditSession) -> None:
|
||||||
|
self._sessions[session.session_id] = session
|
||||||
|
|
||||||
|
def delete(self, session_id: str) -> None:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Explicit state machine router for edit flow.
|
||||||
|
Routes messages based on pending_plan.state: AWAITING_CONFIRM | None (fresh request).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from models import ChatMessage
|
||||||
|
|
||||||
|
from .confirmation import answer_confirmation_question, classify_confirmation_intent
|
||||||
|
from .operations import build_plan_summary
|
||||||
|
from .session_store import EditSession, PendingPlan
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RoutingAction = Literal[
|
||||||
|
"execute",
|
||||||
|
"answer_question",
|
||||||
|
"route_fresh",
|
||||||
|
"error",
|
||||||
|
"already_executed",
|
||||||
|
"cancelled",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StateRoutingResult:
|
||||||
|
"""Result of state routing - tells handler what to do next."""
|
||||||
|
|
||||||
|
action: RoutingAction
|
||||||
|
plan: PendingPlan | None = None
|
||||||
|
message: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
followup_intent: Any | None = None
|
||||||
|
keep_pending: bool | None = None
|
||||||
|
plan_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def route_message(
|
||||||
|
session: EditSession,
|
||||||
|
user_message: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
) -> StateRoutingResult:
|
||||||
|
"""
|
||||||
|
Route message based on pending_plan state.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
No pending_plan → route as fresh request
|
||||||
|
AWAITING_CONFIRM → handle confirmation (confirm/cancel/modify/new_request/question)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StateRoutingResult with action and context
|
||||||
|
"""
|
||||||
|
if not session.pending_plan:
|
||||||
|
return StateRoutingResult(action="route_fresh")
|
||||||
|
|
||||||
|
if session.pending_plan.state == "AWAITING_CONFIRM":
|
||||||
|
return _handle_awaiting_confirm(session, user_message, history)
|
||||||
|
|
||||||
|
logger.error(f"[STATE_ROUTER] Unknown state: {session.pending_plan.state}")
|
||||||
|
return StateRoutingResult(action="error", error="Invalid pending plan state")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_awaiting_confirm(
|
||||||
|
session: EditSession,
|
||||||
|
user_message: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
) -> StateRoutingResult:
|
||||||
|
"""
|
||||||
|
Handle message during AWAITING_CONFIRM state.
|
||||||
|
|
||||||
|
CRITICAL: Never ignore messages. Always classify intent.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- confirm → execute plan
|
||||||
|
- cancel → clear plan
|
||||||
|
- modify/new_request → clear plan + route as fresh
|
||||||
|
- question → answer without executing
|
||||||
|
"""
|
||||||
|
plan = session.pending_plan
|
||||||
|
assert plan is not None
|
||||||
|
|
||||||
|
# Build plan summary for context
|
||||||
|
operations = [op.operation_id for op in plan.ops]
|
||||||
|
plan_summary = build_plan_summary(operations)
|
||||||
|
|
||||||
|
# Classify confirmation intent
|
||||||
|
intent = classify_confirmation_intent(
|
||||||
|
user_message,
|
||||||
|
plan_summary,
|
||||||
|
history,
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not intent:
|
||||||
|
# Fallback: treat as question (safe default)
|
||||||
|
logger.warning("[STATE_ROUTER] No confirmation intent, defaulting to question")
|
||||||
|
intent_action = "question"
|
||||||
|
else:
|
||||||
|
intent_action = intent.action
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[STATE_ROUTER] confirm_state session_id={session.session_id} intent={intent_action} plan_id={plan.plan_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if intent_action == "confirm":
|
||||||
|
# Check idempotency
|
||||||
|
if plan.plan_id in session.executed_plan_ids:
|
||||||
|
return StateRoutingResult(
|
||||||
|
action="already_executed", plan_id=plan.plan_id, message="This plan has already been executed."
|
||||||
|
)
|
||||||
|
|
||||||
|
return StateRoutingResult(
|
||||||
|
action="execute",
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif intent_action == "cancel":
|
||||||
|
# Clear pending plan
|
||||||
|
session.pending_plan = None
|
||||||
|
return StateRoutingResult(
|
||||||
|
action="cancelled", message="Cancelled. Let me know if you want to do something else."
|
||||||
|
)
|
||||||
|
|
||||||
|
elif intent_action in ("modify", "new_request"):
|
||||||
|
# Minimal safe behavior: clear + replan
|
||||||
|
# Don't try to patch - just treat as fresh request
|
||||||
|
logger.info(f"[STATE_ROUTER] {intent_action} detected, clearing plan and routing fresh")
|
||||||
|
session.pending_plan = None
|
||||||
|
return StateRoutingResult(
|
||||||
|
action="route_fresh",
|
||||||
|
message=None, # Don't add extra message, just route
|
||||||
|
)
|
||||||
|
|
||||||
|
elif intent_action == "question":
|
||||||
|
# Answer question without executing
|
||||||
|
answer = answer_confirmation_question(
|
||||||
|
user_message,
|
||||||
|
plan_summary,
|
||||||
|
operations,
|
||||||
|
history,
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
return StateRoutingResult(
|
||||||
|
action="answer_question",
|
||||||
|
message=answer,
|
||||||
|
keep_pending=True, # Keep plan for later confirmation
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"[STATE_ROUTER] Unknown confirmation intent: {intent_action}")
|
||||||
|
return StateRoutingResult(
|
||||||
|
action="answer_question",
|
||||||
|
message="I didn't understand that. Please confirm to proceed or cancel to stop.",
|
||||||
|
keep_pending=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import models
|
||||||
|
from config import FAST_MODEL, SMART_MODEL
|
||||||
|
from editing.params import dump_params
|
||||||
|
from llm_utils import run_ai
|
||||||
|
from models import tool_models
|
||||||
|
from prompts import (
|
||||||
|
ToolParamEntry,
|
||||||
|
ToolParamIndex,
|
||||||
|
edit_followup_intent_prompt,
|
||||||
|
edit_missing_parameter_fill_prompt,
|
||||||
|
edit_tool_clarification_prompt,
|
||||||
|
edit_tool_parameter_fill_prompt,
|
||||||
|
edit_tool_selection_system_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FILE_PARAM_NAMES = {"fileInput", "fileId", "file"}
|
||||||
|
CLARIFICATION_RULES = {
|
||||||
|
"removePassword": {
|
||||||
|
"ask_for": ["password"],
|
||||||
|
"note": "If the user says there is no password or it is empty, set password to an empty string.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolCatalog:
|
||||||
|
operation_ids: list[tool_models.OperationId]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolSelectionEntry:
|
||||||
|
operation_id: tool_models.OperationId
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolOperationDetail:
|
||||||
|
operation_id: tool_models.OperationId
|
||||||
|
clarification: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCatalogService:
|
||||||
|
def __init__(self, *, endpoint_cache_ttl_seconds: float = 60.0) -> None:
|
||||||
|
self._endpoint_cache_ttl_seconds = endpoint_cache_ttl_seconds
|
||||||
|
self._catalog_cache = ToolCatalog(operation_ids=[])
|
||||||
|
self._catalog_cache_ts: float = time.time()
|
||||||
|
self._catalog_cache_initialized = False
|
||||||
|
|
||||||
|
def _build_catalog(self) -> ToolCatalog:
|
||||||
|
all_ops = sorted(tool_models.OPERATIONS.keys())
|
||||||
|
enabled_ops = list(all_ops)
|
||||||
|
excluded_no_file = 0
|
||||||
|
excluded_non_binary = 0
|
||||||
|
excluded_disabled = 0
|
||||||
|
logger.info(
|
||||||
|
"[EDIT] Catalog presort total_ops=%s file_ops=%s excluded_no_file=%s excluded_non_binary=%s excluded_disabled=%s",
|
||||||
|
len(all_ops),
|
||||||
|
len(enabled_ops),
|
||||||
|
excluded_no_file,
|
||||||
|
excluded_non_binary,
|
||||||
|
excluded_disabled,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[EDIT] Enabled operations: %s",
|
||||||
|
"\n".join(str(op_id) for op_id in enabled_ops),
|
||||||
|
)
|
||||||
|
return ToolCatalog(
|
||||||
|
operation_ids=enabled_ops,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_catalog(self) -> ToolCatalog:
|
||||||
|
if not self._catalog_cache_initialized:
|
||||||
|
self._catalog_cache = self._build_catalog()
|
||||||
|
self._catalog_cache_ts = time.time()
|
||||||
|
self._catalog_cache_initialized = True
|
||||||
|
logger.info("[EDIT] Loaded %s file-processing operations", len(self._catalog_cache.operation_ids))
|
||||||
|
elif time.time() - self._catalog_cache_ts >= self._endpoint_cache_ttl_seconds:
|
||||||
|
self._catalog_cache = self._build_catalog()
|
||||||
|
self._catalog_cache_ts = time.time()
|
||||||
|
logger.info("[EDIT] Refreshed file-processing operations=%s", len(self._catalog_cache.operation_ids))
|
||||||
|
return self._catalog_cache
|
||||||
|
|
||||||
|
def build_selection_index(self) -> list[ToolSelectionEntry]:
|
||||||
|
catalog = self.get_catalog()
|
||||||
|
payload: list[ToolSelectionEntry] = []
|
||||||
|
for operation_id in catalog.operation_ids:
|
||||||
|
payload.append(
|
||||||
|
ToolSelectionEntry(
|
||||||
|
operation_id=operation_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def build_catalog_prompt(self) -> str:
|
||||||
|
catalog = self.get_catalog()
|
||||||
|
payload = [self._build_operation_detail(operation_id) for operation_id in catalog.operation_ids]
|
||||||
|
return json.dumps([asdict(item) for item in payload], ensure_ascii=True)
|
||||||
|
|
||||||
|
def _build_operation_detail(
|
||||||
|
self,
|
||||||
|
operation_id: tool_models.OperationId,
|
||||||
|
) -> ToolOperationDetail:
|
||||||
|
return ToolOperationDetail(
|
||||||
|
operation_id=operation_id,
|
||||||
|
clarification=CLARIFICATION_RULES.get(operation_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_operation_parameter_index(
|
||||||
|
self,
|
||||||
|
param_model: tool_models.ParamToolModelType | None,
|
||||||
|
) -> ToolParamIndex:
|
||||||
|
if param_model is None:
|
||||||
|
return ToolParamIndex(params=[])
|
||||||
|
params: list[ToolParamEntry] = []
|
||||||
|
for py_name in sorted(param_model.model_fields.keys()):
|
||||||
|
field = param_model.model_fields[py_name]
|
||||||
|
params.append(
|
||||||
|
ToolParamEntry(
|
||||||
|
name=field.alias or py_name,
|
||||||
|
python_name=py_name,
|
||||||
|
required=field.is_required(),
|
||||||
|
type=str(field.annotation),
|
||||||
|
description=field.description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ToolParamIndex(params=params)
|
||||||
|
|
||||||
|
def select_edit_tool(
|
||||||
|
self,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
uploaded_files: list[models.UploadedFileInfo],
|
||||||
|
preflight: models.PdfPreflight | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> models.EditToolSelection:
|
||||||
|
selection_index = self.build_selection_index()
|
||||||
|
system_instructions = edit_tool_selection_system_prompt(
|
||||||
|
uploaded_files=uploaded_files,
|
||||||
|
preflight=preflight,
|
||||||
|
tool_catalog=[entry.operation_id for entry in selection_index],
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=system_instructions),
|
||||||
|
*history,
|
||||||
|
]
|
||||||
|
response = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
models.EditToolSelection,
|
||||||
|
tag="edit_tool_selection",
|
||||||
|
log_label="edit-tool-selection",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def should_ask_clarification(
|
||||||
|
self,
|
||||||
|
operation_id: tool_models.OperationId,
|
||||||
|
user_message: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
parameters: dict[str, Any],
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> models.ClarificationDecision:
|
||||||
|
op_detail = self._build_operation_detail(operation_id)
|
||||||
|
system_instructions = edit_tool_clarification_prompt()
|
||||||
|
system_payload = {
|
||||||
|
"instructions": system_instructions,
|
||||||
|
"operation": asdict(op_detail),
|
||||||
|
"current_parameters": parameters,
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=[system_payload]),
|
||||||
|
*history,
|
||||||
|
]
|
||||||
|
response = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
models.ClarificationDecision,
|
||||||
|
tag="edit_tool_clarification",
|
||||||
|
log_label="edit-tool-clarification",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def extract_operation_parameters(
|
||||||
|
self,
|
||||||
|
operation_id: tool_models.OperationId,
|
||||||
|
previous_operations: Sequence[tuple[tool_models.OperationId, tool_models.ParamToolModel | None]],
|
||||||
|
user_message: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
preflight: models.PdfPreflight | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> tool_models.ParamToolModel | None:
|
||||||
|
ai_request_model = tool_models.OPERATIONS.get(operation_id)
|
||||||
|
if ai_request_model is None:
|
||||||
|
return None
|
||||||
|
if not issubclass(ai_request_model, models.ApiModel):
|
||||||
|
raise TypeError(f"AI request model must be models.ApiModel, got: {ai_request_model}")
|
||||||
|
param_index = self._build_operation_parameter_index(ai_request_model)
|
||||||
|
system_instructions = edit_tool_parameter_fill_prompt(
|
||||||
|
operation_id=operation_id,
|
||||||
|
preflight=preflight,
|
||||||
|
parameter_catalog=param_index,
|
||||||
|
previous_operations=previous_operations,
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=system_instructions),
|
||||||
|
*history,
|
||||||
|
]
|
||||||
|
result = run_ai(
|
||||||
|
SMART_MODEL,
|
||||||
|
messages,
|
||||||
|
ai_request_model,
|
||||||
|
tag="edit_tool_params",
|
||||||
|
log_label="edit-tool-params",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def fill_missing_parameters(
|
||||||
|
self,
|
||||||
|
operation_id: tool_models.OperationId,
|
||||||
|
user_message: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
missing_parameters: list[str],
|
||||||
|
current_parameters: tool_models.ParamToolModel | None,
|
||||||
|
preflight: models.PdfPreflight | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> tool_models.ParamToolModel | None:
|
||||||
|
ai_request_model = tool_models.OPERATIONS.get(operation_id)
|
||||||
|
if ai_request_model is None:
|
||||||
|
return None
|
||||||
|
if not issubclass(ai_request_model, models.ApiModel):
|
||||||
|
raise TypeError(f"AI request model must be models.ApiModel, got: {ai_request_model}")
|
||||||
|
current_params_dump = dump_params(current_parameters)
|
||||||
|
system_instructions = edit_missing_parameter_fill_prompt()
|
||||||
|
system_payload = {
|
||||||
|
"instructions": system_instructions,
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"missing_parameters": missing_parameters,
|
||||||
|
"current_parameters": current_params_dump,
|
||||||
|
"preflight": preflight.model_dump(by_alias=True, exclude_none=True) if preflight else None,
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=[system_payload]),
|
||||||
|
*history,
|
||||||
|
]
|
||||||
|
allowed = set(missing_parameters)
|
||||||
|
result = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
ai_request_model,
|
||||||
|
tag="edit_missing_fill",
|
||||||
|
log_label="edit-missing-fill",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
params = result.model_dump(by_alias=True, exclude_none=True)
|
||||||
|
filtered = {name: value for name, value in params.items() if name in allowed}
|
||||||
|
return ai_request_model.model_validate(filtered)
|
||||||
|
|
||||||
|
def decide_followup_intent(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
history: list[models.ChatMessage],
|
||||||
|
pending_requirements: list[models.PendingRequirement],
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> models.FollowupIntent:
|
||||||
|
pending_dump = [
|
||||||
|
{
|
||||||
|
"operation_id": requirement.operation_id,
|
||||||
|
"parameters": dump_params(requirement.parameters),
|
||||||
|
"missing": requirement.missing,
|
||||||
|
}
|
||||||
|
for requirement in pending_requirements
|
||||||
|
]
|
||||||
|
system_instructions = edit_followup_intent_prompt()
|
||||||
|
system_payload = {
|
||||||
|
"instructions": system_instructions,
|
||||||
|
"pending_requirements": pending_dump,
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
models.ChatMessage(role="system", content=[system_payload]),
|
||||||
|
*history,
|
||||||
|
models.ChatMessage(role="user", content=user_message),
|
||||||
|
]
|
||||||
|
response = run_ai(
|
||||||
|
FAST_MODEL,
|
||||||
|
messages,
|
||||||
|
models.FollowupIntent,
|
||||||
|
tag="edit_followup_intent",
|
||||||
|
log_label="edit-followup-intent",
|
||||||
|
log_exchange=True,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_operation(self, operation_id: tool_models.OperationId) -> tool_models.ParamToolModelType | None:
|
||||||
|
return tool_models.OPERATIONS.get(operation_id)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Format-specific outline extraction prompts.
|
||||||
|
|
||||||
|
Each format has a dedicated prompt that instructs the AI to EXTRACT values
|
||||||
|
from the user's input rather than fabricate them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .advisor_agreement import ADVISOR_AGREEMENT_PROMPT, ADVISOR_AGREEMENT_SECTIONS
|
||||||
|
from .audit_report import AUDIT_REPORT_PROMPT, AUDIT_REPORT_SECTIONS
|
||||||
|
from .board_minutes import BOARD_MINUTES_PROMPT, BOARD_MINUTES_SECTIONS
|
||||||
|
from .budget_proposal import BUDGET_PROPOSAL_PROMPT, BUDGET_PROPOSAL_SECTIONS
|
||||||
|
from .case_study import CASE_STUDY_PROMPT, CASE_STUDY_SECTIONS
|
||||||
|
from .committee_agenda import COMMITTEE_AGENDA_PROMPT, COMMITTEE_AGENDA_SECTIONS
|
||||||
|
from .contract import CONTRACT_PROMPT, CONTRACT_SECTIONS
|
||||||
|
from .cover_letter import COVER_LETTER_PROMPT, COVER_LETTER_SECTIONS
|
||||||
|
from .employee_handbook import EMPLOYEE_HANDBOOK_PROMPT, EMPLOYEE_HANDBOOK_SECTIONS
|
||||||
|
from .executive_summary import EXECUTIVE_SUMMARY_PROMPT, EXECUTIVE_SUMMARY_SECTIONS
|
||||||
|
from .expense_report import EXPENSE_REPORT_PROMPT, EXPENSE_REPORT_SECTIONS
|
||||||
|
from .incident_report import INCIDENT_REPORT_PROMPT, INCIDENT_REPORT_SECTIONS
|
||||||
|
from .independent_contractor_agreement import (
|
||||||
|
INDEPENDENT_CONTRACTOR_AGREEMENT_PROMPT,
|
||||||
|
INDEPENDENT_CONTRACTOR_AGREEMENT_SECTIONS,
|
||||||
|
)
|
||||||
|
from .invoice import INVOICE_PROMPT, INVOICE_SECTIONS
|
||||||
|
from .job_description import JOB_DESCRIPTION_PROMPT, JOB_DESCRIPTION_SECTIONS
|
||||||
|
from .letter import LETTER_PROMPT, LETTER_SECTIONS
|
||||||
|
from .letter_of_intent import LETTER_OF_INTENT_PROMPT, LETTER_OF_INTENT_SECTIONS
|
||||||
|
from .master_services_agreement import MASTER_SERVICES_AGREEMENT_PROMPT, MASTER_SERVICES_AGREEMENT_SECTIONS
|
||||||
|
from .meeting_agenda import MEETING_AGENDA_PROMPT, MEETING_AGENDA_SECTIONS
|
||||||
|
from .meeting_minutes import MEETING_MINUTES_PROMPT, MEETING_MINUTES_SECTIONS
|
||||||
|
from .nda import NDA_PROMPT, NDA_SECTIONS
|
||||||
|
from .offer_letter import OFFER_LETTER_PROMPT, OFFER_LETTER_SECTIONS
|
||||||
|
from .official_memo import OFFICIAL_MEMO_PROMPT, OFFICIAL_MEMO_SECTIONS
|
||||||
|
from .one_pager import ONE_PAGER_PROMPT, ONE_PAGER_SECTIONS
|
||||||
|
from .pay_stub import PAY_STUB_PROMPT, PAY_STUB_SECTIONS
|
||||||
|
from .performance_review import PERFORMANCE_REVIEW_PROMPT, PERFORMANCE_REVIEW_SECTIONS
|
||||||
|
from .press_release import PRESS_RELEASE_PROMPT, PRESS_RELEASE_SECTIONS
|
||||||
|
from .price_sheet import PRICE_SHEET_PROMPT, PRICE_SHEET_SECTIONS
|
||||||
|
from .privacy_policy import PRIVACY_POLICY_PROMPT, PRIVACY_POLICY_SECTIONS
|
||||||
|
from .proposal import PROPOSAL_PROMPT, PROPOSAL_SECTIONS
|
||||||
|
from .public_notice import PUBLIC_NOTICE_PROMPT, PUBLIC_NOTICE_SECTIONS
|
||||||
|
from .purchase_order import PURCHASE_ORDER_PROMPT, PURCHASE_ORDER_SECTIONS
|
||||||
|
from .quote import QUOTE_PROMPT, QUOTE_SECTIONS
|
||||||
|
from .receipt import RECEIPT_PROMPT, RECEIPT_SECTIONS
|
||||||
|
from .report import REPORT_PROMPT, REPORT_SECTIONS
|
||||||
|
from .resume import RESUME_PROMPT, RESUME_SECTIONS
|
||||||
|
from .safe_agreement import SAFE_AGREEMENT_PROMPT, SAFE_AGREEMENT_SECTIONS
|
||||||
|
from .separation_notice import SEPARATION_NOTICE_PROMPT, SEPARATION_NOTICE_SECTIONS
|
||||||
|
from .service_agreement import SERVICE_AGREEMENT_PROMPT, SERVICE_AGREEMENT_SECTIONS
|
||||||
|
from .standard_operating_procedures import STANDARD_OPERATING_PROCEDURES_PROMPT, STANDARD_OPERATING_PROCEDURES_SECTIONS
|
||||||
|
from .statement_of_work import STATEMENT_OF_WORK_PROMPT, STATEMENT_OF_WORK_SECTIONS
|
||||||
|
from .terms_of_service import TERMS_OF_SERVICE_PROMPT, TERMS_OF_SERVICE_SECTIONS
|
||||||
|
|
||||||
|
# Fallback prompt for "other" or unknown document types
|
||||||
|
# This is GENERATIVE (not extraction-based) - allows AI to determine structure
|
||||||
|
OTHER_PROMPT = """You are an outline generator for document creation.
|
||||||
|
|
||||||
|
The user wants to create a document but hasn't specified a standard type. Your job is to:
|
||||||
|
1. Understand what they want to create
|
||||||
|
2. Generate a sensible outline with appropriate sections
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Create 5-9 sections that make sense for their request
|
||||||
|
- Each section should have a title and brief description (6-12 words)
|
||||||
|
- Format: "Section Title: Brief description of what goes here"
|
||||||
|
- Be creative and tailor the outline to their specific needs
|
||||||
|
- If they ask for something unusual, do your best to structure it logically
|
||||||
|
|
||||||
|
Output format (one section per line):
|
||||||
|
Title: [document title/name]
|
||||||
|
Section 1: Description of section 1
|
||||||
|
Section 2: Description of section 2
|
||||||
|
...and so on
|
||||||
|
|
||||||
|
If the user asks to make up or fake data, you can generate appropriate placeholder content."""
|
||||||
|
|
||||||
|
OTHER_SECTIONS = [
|
||||||
|
"Title",
|
||||||
|
"Introduction",
|
||||||
|
"Main Content",
|
||||||
|
"Details",
|
||||||
|
"Conclusion",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Map document types to their prompts and default sections
|
||||||
|
FORMAT_PROMPTS = {
|
||||||
|
# Popular / Legacy
|
||||||
|
"invoice": (INVOICE_PROMPT, INVOICE_SECTIONS),
|
||||||
|
"resume": (RESUME_PROMPT, RESUME_SECTIONS),
|
||||||
|
"cover_letter": (COVER_LETTER_PROMPT, COVER_LETTER_SECTIONS),
|
||||||
|
"contract": (CONTRACT_PROMPT, CONTRACT_SECTIONS),
|
||||||
|
"nda": (NDA_PROMPT, NDA_SECTIONS),
|
||||||
|
"meeting_agenda": (MEETING_AGENDA_PROMPT, MEETING_AGENDA_SECTIONS),
|
||||||
|
"agenda": (MEETING_AGENDA_PROMPT, MEETING_AGENDA_SECTIONS),
|
||||||
|
# Legal
|
||||||
|
"terms_of_service": (TERMS_OF_SERVICE_PROMPT, TERMS_OF_SERVICE_SECTIONS),
|
||||||
|
"privacy_policy": (PRIVACY_POLICY_PROMPT, PRIVACY_POLICY_SECTIONS),
|
||||||
|
# Financial
|
||||||
|
"quote": (QUOTE_PROMPT, QUOTE_SECTIONS),
|
||||||
|
"estimate": (QUOTE_PROMPT, QUOTE_SECTIONS),
|
||||||
|
"receipt": (RECEIPT_PROMPT, RECEIPT_SECTIONS),
|
||||||
|
"expense_report": (EXPENSE_REPORT_PROMPT, EXPENSE_REPORT_SECTIONS),
|
||||||
|
# Business
|
||||||
|
"proposal": (PROPOSAL_PROMPT, PROPOSAL_SECTIONS),
|
||||||
|
"report": (REPORT_PROMPT, REPORT_SECTIONS),
|
||||||
|
"letter": (LETTER_PROMPT, LETTER_SECTIONS),
|
||||||
|
"one_pager": (ONE_PAGER_PROMPT, ONE_PAGER_SECTIONS),
|
||||||
|
"statement_of_work": (STATEMENT_OF_WORK_PROMPT, STATEMENT_OF_WORK_SECTIONS),
|
||||||
|
"sow": (STATEMENT_OF_WORK_PROMPT, STATEMENT_OF_WORK_SECTIONS),
|
||||||
|
"meeting_minutes": (MEETING_MINUTES_PROMPT, MEETING_MINUTES_SECTIONS),
|
||||||
|
"minutes": (MEETING_MINUTES_PROMPT, MEETING_MINUTES_SECTIONS),
|
||||||
|
"press_release": (PRESS_RELEASE_PROMPT, PRESS_RELEASE_SECTIONS),
|
||||||
|
# Governance
|
||||||
|
"official_memo": (OFFICIAL_MEMO_PROMPT, OFFICIAL_MEMO_SECTIONS),
|
||||||
|
"board_minutes": (BOARD_MINUTES_PROMPT, BOARD_MINUTES_SECTIONS),
|
||||||
|
"committee_agenda": (COMMITTEE_AGENDA_PROMPT, COMMITTEE_AGENDA_SECTIONS),
|
||||||
|
"executive_summary": (EXECUTIVE_SUMMARY_PROMPT, EXECUTIVE_SUMMARY_SECTIONS),
|
||||||
|
"incident_report": (INCIDENT_REPORT_PROMPT, INCIDENT_REPORT_SECTIONS),
|
||||||
|
"public_notice": (PUBLIC_NOTICE_PROMPT, PUBLIC_NOTICE_SECTIONS),
|
||||||
|
# Contracts
|
||||||
|
"service_agreement": (SERVICE_AGREEMENT_PROMPT, SERVICE_AGREEMENT_SECTIONS),
|
||||||
|
"independent_contractor_agreement": (
|
||||||
|
INDEPENDENT_CONTRACTOR_AGREEMENT_PROMPT,
|
||||||
|
INDEPENDENT_CONTRACTOR_AGREEMENT_SECTIONS,
|
||||||
|
),
|
||||||
|
"safe_agreement": (SAFE_AGREEMENT_PROMPT, SAFE_AGREEMENT_SECTIONS),
|
||||||
|
"advisor_agreement": (ADVISOR_AGREEMENT_PROMPT, ADVISOR_AGREEMENT_SECTIONS),
|
||||||
|
"nondisclosure_agreement": (NDA_PROMPT, NDA_SECTIONS),
|
||||||
|
"master_services_agreement": (MASTER_SERVICES_AGREEMENT_PROMPT, MASTER_SERVICES_AGREEMENT_SECTIONS),
|
||||||
|
# Finance
|
||||||
|
"purchase_order": (PURCHASE_ORDER_PROMPT, PURCHASE_ORDER_SECTIONS),
|
||||||
|
"budget_proposal": (BUDGET_PROPOSAL_PROMPT, BUDGET_PROPOSAL_SECTIONS),
|
||||||
|
"audit_report": (AUDIT_REPORT_PROMPT, AUDIT_REPORT_SECTIONS),
|
||||||
|
# Sales
|
||||||
|
"case_study": (CASE_STUDY_PROMPT, CASE_STUDY_SECTIONS),
|
||||||
|
"letter_of_intent": (LETTER_OF_INTENT_PROMPT, LETTER_OF_INTENT_SECTIONS),
|
||||||
|
"price_sheet": (PRICE_SHEET_PROMPT, PRICE_SHEET_SECTIONS),
|
||||||
|
# Human Resources
|
||||||
|
"job_description": (JOB_DESCRIPTION_PROMPT, JOB_DESCRIPTION_SECTIONS),
|
||||||
|
"offer_letter": (OFFER_LETTER_PROMPT, OFFER_LETTER_SECTIONS),
|
||||||
|
"pay_stub": (PAY_STUB_PROMPT, PAY_STUB_SECTIONS),
|
||||||
|
"payslip": (PAY_STUB_PROMPT, PAY_STUB_SECTIONS),
|
||||||
|
"separation_notice": (SEPARATION_NOTICE_PROMPT, SEPARATION_NOTICE_SECTIONS),
|
||||||
|
"performance_review": (PERFORMANCE_REVIEW_PROMPT, PERFORMANCE_REVIEW_SECTIONS),
|
||||||
|
"employee_handbook": (EMPLOYEE_HANDBOOK_PROMPT, EMPLOYEE_HANDBOOK_SECTIONS),
|
||||||
|
"standard_operating_procedures": (STANDARD_OPERATING_PROCEDURES_PROMPT, STANDARD_OPERATING_PROCEDURES_SECTIONS),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Categories for frontend tabs
|
||||||
|
CATEGORIES = {
|
||||||
|
"popular": ["resume", "invoice", "cover_letter", "meeting_agenda", "contract", "nda"],
|
||||||
|
"legal": ["contract", "nda", "terms_of_service", "privacy_policy"],
|
||||||
|
"financial": ["invoice", "quote", "receipt", "expense_report"],
|
||||||
|
"business": ["proposal", "report", "letter", "one_pager", "statement_of_work", "meeting_minutes", "press_release"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_format_prompt(document_type: str) -> tuple[str | None, list[str] | None]:
|
||||||
|
"""
|
||||||
|
Get the prompt and default sections for a document type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (prompt, sections) - both strings/lists, or (None, None) if not found
|
||||||
|
|
||||||
|
For "other" or unknown types, returns the generative prompt
|
||||||
|
which allows the AI to determine appropriate structure.
|
||||||
|
"""
|
||||||
|
doc_type = document_type.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
|
||||||
|
# Check if we have a specific format prompt
|
||||||
|
if doc_type in FORMAT_PROMPTS:
|
||||||
|
return FORMAT_PROMPTS[doc_type]
|
||||||
|
|
||||||
|
# For "other", "document", or unknown types, return the generative prompt
|
||||||
|
if doc_type in ("other", "document", "miscellaneous", "unknown", ""):
|
||||||
|
return (OTHER_PROMPT, OTHER_SECTIONS)
|
||||||
|
|
||||||
|
# Unknown type - return None to use default behavior in ai_generation.py
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def has_format_prompt(document_type: str) -> bool:
|
||||||
|
"""Check if a document type has a dedicated format prompt."""
|
||||||
|
doc_type = document_type.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
return doc_type in FORMAT_PROMPTS
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FORMAT_PROMPTS",
|
||||||
|
"CATEGORIES",
|
||||||
|
"get_format_prompt",
|
||||||
|
"has_format_prompt",
|
||||||
|
"OTHER_PROMPT",
|
||||||
|
"OTHER_SECTIONS",
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user