diff --git a/.claude/fable5/FABLE5_EXECUTION_PRD.md b/.claude/fable5/FABLE5_EXECUTION_PRD.md new file mode 100644 index 000000000..28d41ade4 --- /dev/null +++ b/.claude/fable5/FABLE5_EXECUTION_PRD.md @@ -0,0 +1,87 @@ +FABLE5 AUTONOMOUS EXECUTION PRD +InvoicePlane-v2 + +──────────────────────────────────────── +PURPOSE +──────────────────────────────────────── +Fable5 processes GitHub issues into draft PRs while reusing existing branches from: +underdogg-forks/invoiceplane-v2 + +PRs already exist in: +invoiceplane/invoiceplane-v2 + +Branches already exist in: +underdogg-forks/invoiceplane-v2 + +Fable5 must reconcile both systems. + +──────────────────────────────────────── +CRITICAL RULE +──────────────────────────────────────── +NEVER CREATE NEW BRANCHES IF A PR-BOUND BRANCH ALREADY EXISTS. + +Always reuse: +- existing PR branches +- existing fork branches + +Branch identity is authoritative. + +──────────────────────────────────────── +SOURCE OF TRUTH PRIORITY +──────────────────────────────────────── +1. Existing GitHub PR (invoiceplane/invoiceplane-v2) +2. Existing branch in fork (underdogg-forks/invoiceplane-v2) +3. Issue definition +4. Repository code state + +──────────────────────────────────────── +EXECUTION MODEL +──────────────────────────────────────── +- Iterate through all provided issue IDs +- For each issue: + - locate existing PR + - extract associated branch from fork + - checkout and continue work on that branch + - do NOT reinitialize branch + +──────────────────────────────────────── +BRANCH REUSE RULE +──────────────────────────────────────── +If PR exists: +- fetch PR branch from upstream or fork +- checkout branch locally +- continue commits + +If PR does NOT exist: +- only then create new branch + +──────────────────────────────────────── +COMMIT POLICY +──────────────────────────────────────── +- frequent commits required +- atomic logical changes only +- never mix multiple issues unless explicitly grouped + +──────────────────────────────────────── +PR POLICY +──────────────────────────────────────── +- all PRs must remain DRAFT +- PR title format: + [IP-{issueId}] description +- PR body must be updated, never replaced blindly +- preserve GitHub discussion history + +──────────────────────────────────────── +FAILURE HANDLING +──────────────────────────────────────── +If branch cannot be found: +- attempt fetch from: + underdogg-forks/invoiceplane-v2 +- if still missing: + skip issue and log reason + +──────────────────────────────────────── +EXECUTION END CONDITION +──────────────────────────────────────── +Stop when: +- all issues processed OR skipped diff --git a/.claude/fable5/Fable5PolicyLoader.php b/.claude/fable5/Fable5PolicyLoader.php new file mode 100644 index 000000000..7660a812e --- /dev/null +++ b/.claude/fable5/Fable5PolicyLoader.php @@ -0,0 +1,37 @@ + $this->loadFile('.claude/fable5/FABLE5_EXECUTION_PRD.md'), + 'skills' => $this->loadDirectory('.claude/fable5/skills'), + 'runtime' => $this->loadFile('.claude/fable5/runtime/overrides.md'), + 'repo' => $this->loadFile('CLAUDE.md'), + ]; + } + + private function loadFile(string $path): array + { + return file_exists($path) + ? [file_get_contents($path)] + : []; + } + + private function loadDirectory(string $path): array + { + if (!is_dir($path)) { + return []; + } + + $files = glob($path . '/*.md'); + + return array_map( + fn ($file) => file_get_contents($file), + $files + ); + } +} diff --git a/.claude/fable5/prd/EXECUTION_BOOT_FLOW.md b/.claude/fable5/prd/EXECUTION_BOOT_FLOW.md new file mode 100644 index 000000000..544c4170c --- /dev/null +++ b/.claude/fable5/prd/EXECUTION_BOOT_FLOW.md @@ -0,0 +1,75 @@ +FABLE5 EXECUTION BOOT FLOW + +──────────────────────────────────────── +PHASE 0 — SYSTEM INITIALIZATION +──────────────────────────────────────── +Before any issue execution begins, Fable5 MUST build a deterministic execution graph. + +This step is mandatory and must complete successfully before any branch work starts. + +──────────────────────────────────────── +PHASE 1 — DATA COLLECTION +──────────────────────────────────────── +Fetch the following sources: + +1. All open PRs from: + invoiceplane/invoiceplane-v2 + +2. All branches from: + underdogg-forks/invoiceplane-v2 + +3. Input issue list (static execution payload) + +──────────────────────────────────────── +PHASE 2 — RECONCILIATION +──────────────────────────────────────── +Build an ExecutionGraph by mapping: + +Issue ID → +Existing PR → +Associated branch (if available) → +Fork branch state + +Rules: + +- If PR exists AND branch exists in fork: + → mark node as EXISTING_PR + +- If PR exists BUT branch missing: + → mark node as PR_MISSING_BRANCH + +- If branch exists BUT no PR: + → mark node as ORPHAN_BRANCH + +- If neither exists: + → mark node as NEW + +──────────────────────────────────────── +PHASE 3 — EXECUTION STRATEGY GENERATION +──────────────────────────────────────── +Fable5 MUST derive execution order from graph: + +Priority order: +1. EXISTING_PR (reuse and continue work) +2. ORPHAN_BRANCH (recover and attach to PR if needed) +3. PR_MISSING_BRANCH (repair state) +4. NEW (create fresh branches) + +──────────────────────────────────────── +PHASE 4 — PARALLELIZATION PLAN +──────────────────────────────────────── +Fable5 may execute branches in parallel only if: + +- no shared module writes exist +- no overlapping DTO / Service modifications occur + +Otherwise execution must be serialized per module lock rules. + +──────────────────────────────────────── +PHASE 5 — EXECUTION HANDOFF +──────────────────────────────────────── +Only after graph is complete: + +→ begin issue processing loop +→ reuse branches from graph +→ never recreate existing execution state diff --git a/.claude/fable5/runtime/overrides.md b/.claude/fable5/runtime/overrides.md new file mode 100644 index 000000000..2b56985ee --- /dev/null +++ b/.claude/fable5/runtime/overrides.md @@ -0,0 +1,7 @@ +RUNTIME OVERRIDES + +- allow_reuse_existing_branches=true +- forbid_branch_recreation=true +- execution_mode=continuous +- concurrency=enabled +- commit_frequency=high diff --git a/.claude/fable5/skills/concurrency.md b/.claude/fable5/skills/concurrency.md new file mode 100644 index 000000000..73a295ff2 --- /dev/null +++ b/.claude/fable5/skills/concurrency.md @@ -0,0 +1,26 @@ +CONCURRENCY RULES + +──────────────────────────────────────── +PARALLEL EXECUTION +──────────────────────────────────────── +Allowed only when: +- branches belong to different PRs +- no shared module writes + +──────────────────────────────────────── +MODULE LOCKING +──────────────────────────────────────── +A module is locked when: +- a branch is actively modifying it + +No concurrent edits allowed on: +- same Service +- same DTO +- same Filament Resource + +──────────────────────────────────────── +SAFE PARALLEL MODEL +──────────────────────────────────────── +Each PR branch is an isolated execution unit. + +No cross-branch writes to same module. diff --git a/.claude/fable5/skills/git-reuse.md b/.claude/fable5/skills/git-reuse.md new file mode 100644 index 000000000..96ecf0c12 --- /dev/null +++ b/.claude/fable5/skills/git-reuse.md @@ -0,0 +1,29 @@ +PR + BRANCH REUSE POLICY + +──────────────────────────────────────── +CORE PRINCIPLE +──────────────────────────────────────── +Existing work is authoritative. + +If a branch exists in: +underdogg-forks/invoiceplane-v2 + +and is linked to a PR in: +invoiceplane/invoiceplane-v2 + +it MUST be reused. + +──────────────────────────────────────── +MAPPING RULE +──────────────────────────────────────── +Issue ID → PR → Branch → Fork repository state + +This mapping is immutable during execution. + +──────────────────────────────────────── +NO DUPLICATION RULE +──────────────────────────────────────── +Never: +- recreate PR branch +- reinitialize git history +- reapply already existing commits diff --git a/.claude/fable5/skills/git.md b/.claude/fable5/skills/git.md new file mode 100644 index 000000000..c577068fa --- /dev/null +++ b/.claude/fable5/skills/git.md @@ -0,0 +1,34 @@ +GIT EXECUTION RULES + +──────────────────────────────────────── +BRANCH DISCOVERY +──────────────────────────────────────── +Always resolve branches in this order: + +1. GitHub PR branch reference +2. local fork (underdogg-forks/invoiceplane-v2) +3. remote origin fallback + +Never create a branch if a PR-linked branch exists. + +──────────────────────────────────────── +BRANCH CHECKOUT RULE +──────────────────────────────────────── +When PR exists: +- fetch PR head ref +- checkout exact branch +- continue history + +No rebase unless explicitly required by issue. + +──────────────────────────────────────── +COMMIT RULES +──────────────────────────────────────── +- atomic commits only +- one logical change per commit +- frequent commits required + +──────────────────────────────────────── +SAFETY RULE +──────────────────────────────────────── +Never overwrite branch history that already belongs to a PR. diff --git a/.claude/skills/abstract-seeder/SKILL.md b/.claude/skills/abstract-seeder/SKILL.md deleted file mode 100644 index aff6abefa..000000000 --- a/.claude/skills/abstract-seeder/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: abstract-seeder -description: Provides structured seeding workflow for module data initialization ---- - -# Abstract Seeder - -## Purpose - -Provides a structured way to seed database data per module. - ---- - -## Scope - -Seeders are responsible for: - -- creating initial dataset for a company -- using factories to generate valid records -- orchestrating dependency order between models - ---- - -## Ownership Boundary - -Seeders MUST NOT: - -- define validation rules -- define factory structure -- enforce schema constraints -- contain business logic - ---- - -## Factory Dependency Rule - -Seeders MUST rely on factories for object creation. - -Factories are the source of truth for valid model state. - ---- - -## Dependency Resolution - -Seeders MAY resolve dependencies using helper methods: - -- findOrCreateClient -- findOrCreateProject -- findOrCreateUser - -These helpers are convenience utilities, not business logic. - ---- - -## Execution Hooks - -- beforeSeed(): setup state -- afterSeed(): cleanup or summary - ---- - -## Principle - -Seeders assemble data. They do not define data correctness. diff --git a/.claude/skills/ci-schema-invariant-gate/SKILL.md b/.claude/skills/ci-schema-invariant-gate/SKILL.md index 51dcc7164..544e875cd 100644 --- a/.claude/skills/ci-schema-invariant-gate/SKILL.md +++ b/.claude/skills/ci-schema-invariant-gate/SKILL.md @@ -45,8 +45,8 @@ These are handled by other skills. If CI fails: -- migrations failing → schema issue (handled by test-honesty) -- seed failing → factory/data issue (handled by test-honesty) +- migrations failing → schema issue (handled by data-layer-contracts) +- seed failing → factory/data issue (handled by data-layer-contracts) - tests failing → behavior issue (handled by test layer) CI does NOT interpret or classify failures. diff --git a/.claude/skills/data-layer-contracts/SKILL.md b/.claude/skills/data-layer-contracts/SKILL.md new file mode 100644 index 000000000..945f89384 --- /dev/null +++ b/.claude/skills/data-layer-contracts/SKILL.md @@ -0,0 +1,73 @@ +--- +name: data-layer-contracts +description: Defines the schema-to-factory-to-seeder contract chain — NOT NULL alignment, factory/seeder ownership boundaries, and schema drift rules +--- + +# Data Layer Contracts + +## Purpose + +Defines the contract chain from migration schema to factory to seeder, and prevents drift between them. + +--- + +## Schema Contract + +Every NOT NULL column defined in a migration must be supported by: + +- a factory definition, or +- a seeder definition (only for seed data), or +- an explicit DB default in the migration + +This is a **schema-only rule**, not a validation rule. MySQL/MariaDB is the canonical database — SQLite differences are invalid for schema validation assumptions. + +--- + +## Factory Rules + +Factories MUST: + +- satisfy all NOT NULL columns +- reflect migration constraints +- represent the smallest valid persisted entity — not random data, not business scenarios, only valid schema state + +Factories MUST NOT: + +- enforce business rules or validation rules +- replace service-layer creation logic +- define seeder logic, or depend on seeders + +If a migration introduces a NOT NULL column, the factory MUST be updated immediately — omission is invalid state. + +Factories SHOULD align with service-layer expectations but do NOT depend on it: service layer = behavior, factory = valid structure. + +--- + +## Seeder Rules + +Seeders MUST: + +- rely on factories for object creation — factories are the source of truth for valid model state +- orchestrate dependency order between models, optionally via helpers (findOrCreateClient, findOrCreateProject, findOrCreateUser — convenience utilities, not business logic) +- only insert schema-valid data, with no reliance on implicit database defaults + +Seeders MUST NOT: + +- define validation rules, factory structure, or schema constraints +- contain business logic + +Seeders assemble data. They do not define data correctness. + +--- + +## Drift Triggers + +The following indicate schema drift: migration changes, factory mismatch, seeder mismatch, SQLSTATE constraint violations, CI vs local DB mismatch. + +Schema validation requires `migrate:fresh` + `seed` before running test suites. + +--- + +## Identity Rule + +Primary keys are non-deterministic. Tests MUST NOT rely on hardcoded IDs. diff --git a/.claude/skills/factory-contract-system/SKILL.md b/.claude/skills/factory-contract-system/SKILL.md deleted file mode 100644 index c14b8cdd2..000000000 --- a/.claude/skills/factory-contract-system/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: factory-contract-system -description: Ensures factories generate valid model instances aligned with database schema constraints ---- - -# Factory Contract System - -## Purpose - -Ensures factories produce valid database-ready model instances. - ---- - -## Scope - -Factories MUST: - -- satisfy all NOT NULL columns -- reflect migration constraints -- produce valid default state for persistence - ---- - -## Ownership Boundary - -Factories do NOT: - -- enforce business rules -- define validation rules -- replace service-layer creation logic -- define seeder logic - ---- - -## Schema Alignment Rule - -If a migration introduces a NOT NULL column: - -- factory MUST be updated immediately -- omission is considered invalid state - ---- - -## Minimum Valid State - -Each factory represents the smallest valid persisted entity. - -Not random data. -Not business scenarios. -Only valid schema state. - ---- - -## Service Alignment - -Factories SHOULD align with service-layer expectations but do NOT depend on it. - -Service layer = behavior -Factory = valid structure - ---- - -## Seeder Rule - -Seeders depend on factories. - -Factories MUST NOT depend on seeders. diff --git a/.claude/skills/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md index 548882fc9..9ec86bb31 100644 --- a/.claude/skills/pest-control/SKILL.md +++ b/.claude/skills/pest-control/SKILL.md @@ -1,10 +1,8 @@ --- name: pest-control description: > - Enforces PHPUnit-only testing in this project. Activates when writing tests, reviewing test - files, or when any Pest syntax appears (it(), test(), describe(), uses(), expect() chains, - beforeEach/afterEach hooks). Scans for and eliminates all Pest references from code, - config, and documentation. + Detects and removes Pest syntax (it/test/describe/uses/expect) from code, config, and docs — + this project is PHPUnit-only. license: MIT metadata: author: project diff --git a/.claude/skills/safe-refactoring-rules/SKILL.md b/.claude/skills/safe-refactoring-rules/SKILL.md index 1a42b7a4f..d97461a30 100644 --- a/.claude/skills/safe-refactoring-rules/SKILL.md +++ b/.claude/skills/safe-refactoring-rules/SKILL.md @@ -99,7 +99,7 @@ Duplicate abstractions are architectural defects. This skill does NOT define: - architecture layering (handled by application-architecture-standard) -- testing strategy (handled by test-honesty / filament-resource-testing) +- testing strategy (handled by data-layer-contracts / filament-resource-testing) - security rules (handled separately if present) It ONLY defines safe transformation rules. diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review-checklist/SKILL.md similarity index 97% rename from .claude/skills/security-review/SKILL.md rename to .claude/skills/security-review-checklist/SKILL.md index 3533725db..a625973f9 100644 --- a/.claude/skills/security-review/SKILL.md +++ b/.claude/skills/security-review-checklist/SKILL.md @@ -1,5 +1,5 @@ --- -name: security-review +name: security-review-checklist description: Static review rules for authorization, validation, and privilege escalation risks --- diff --git a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md index 72fcf8347..49557130c 100644 --- a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md +++ b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md @@ -28,11 +28,11 @@ Do not invent rules. Delegate evaluation to existing skills: **Tests** - `filament-resource-testing` -- `test-honesty` +- `data-layer-contracts` - `pest-control` **Security** -- `security-review` +- `security-review-checklist` - `spatie-roles` **Tenancy** @@ -60,7 +60,7 @@ Good example: Focus on: - Tests that pass even when the feature is broken (assertion on wrong thing) - Missing failure-path tests -- Hardcoded IDs (violates `test-honesty`) +- Hardcoded IDs (violates `data-layer-contracts`) - Pest syntax in a PHPUnit-only project - Livewire tests that bypass the service layer and assert nothing in the DB - **Missing `/* Arrange */` / `/* Act */` / `/* Assert */` phase comments** — every test method requires all three, no exceptions diff --git a/.claude/skills/sync-stale-branches/SKILL.md b/.claude/skills/sync-stale-branches/SKILL.md deleted file mode 100644 index 134666808..000000000 --- a/.claude/skills/sync-stale-branches/SKILL.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -name: sync-stale-branches -description: Brings diverged remote branches up to date with develop — classifies, rescues unique work, then resets or deletes stale branches ---- - -# Skill: sync-stale-branches - -Bring old/diverged remote branches up to date with `develop`. -Run this periodically to keep the branch list clean and PR-able. - ---- - -## Inputs - -- `EXCLUDE` — branches to leave untouched (space-separated, no `origin/` prefix) - Default: `develop master` - ---- - -## Step 1 — List candidate branches - -```bash -git fetch --prune - -# All remote branches minus the exclude list -git branch -r | grep -v 'origin/HEAD' \ - | sed 's|remotes/||' \ - | grep -v -E '^origin/(develop|master)$' -``` - -Add any other branches to exclude to the grep pattern. - ---- - -## Step 2 — Classify each branch - -For every candidate `origin/`: - -**A — unique file count (three-dot diff from merge-base):** -```bash -git diff --name-only origin/develop...origin/ | wc -l -``` - -**B — files ONLY in the branch (not in develop):** -```bash -git diff --name-only --diff-filter=A origin/develop origin/ -``` - -Classify as: -- **EMPTY** — A = 0 AND B = 0 → branch adds nothing, safe to delete -- **COVERED** — B > 0 but every file in B is already present in a known feature branch → safe to reset -- **HAS_UNIQUE** — B > 0 with at least one file not in any feature branch → must rescue first - ---- - -## Step 3 — Handle EMPTY branches - -These branches were never extended beyond the old fork point. - -```bash -git push origin --delete -``` - ---- - -## Step 4 — Handle COVERED branches - -All unique files are already captured in a feature branch we are keeping. -Reset the branch to develop HEAD so it is current but carries no stale code. - -```bash -git push origin origin/develop:refs/heads/ --force -``` - ---- - -## Step 5 — Handle HAS_UNIQUE branches - -Rescue uncovered files before resetting. - -### 5a — Identify which feature branch the files belong to - -Group uncovered files by module/domain: -- `Modules/Foo/…` → belongs to whatever feature owns Foo -- If unclear, create a new feature branch named after the owning issue/feature - -### 5b — Extract files onto the correct feature branch - -On the target feature branch (must already exist and be ahead of develop): - -```bash -git checkout origin/ -- ... -git add -git commit -m "chore: rescue from stale " -git push origin HEAD --force-with-lease -``` - -If the target feature branch does not yet exist, use the feature-branch-extraction -procedure to create it properly on top of develop HEAD first. - -### 5c — Reset the stale branch to develop - -```bash -git push origin origin/develop:refs/heads/ --force -``` - ---- - -## Step 6 — Verify - -```bash -# Confirm each branch is now equal to develop -for branch in ; do - ahead=$(git rev-list origin/develop..origin/$branch --count) - behind=$(git rev-list origin/$branch..origin/develop --count) - echo "$branch → ahead=$ahead behind=$behind" -done -``` - -Expected: all cleaned branches show `ahead=0 behind=0`. - ---- - -## Notes - -- Only force-push to branches that are NOT open PRs unless the PR is yours and you - intend to update it. -- GitHub Copilot branches (`copilot/*`) are AI-generated; resetting them is safe — - Copilot will recreate them if needed. -- The `--diff-filter=A` flag catches files the branch **adds** that develop lacks. - Files the branch **modifies** relative to develop but which also exist in develop - are not "unique" — develop's version is preferred. -- Run `git fetch --prune` first so local remote-tracking refs are current. diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md deleted file mode 100644 index 5fd2f26cf..000000000 --- a/.claude/skills/tailwindcss-development/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: tailwindcss-development -description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." -license: MIT -metadata: - author: laravel ---- - -# Tailwind CSS Development - -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - -## Documentation - -Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. - -## Basic Usage - -- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. -- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). -- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. - -## Tailwind CSS v4 Specifics - -- Always use Tailwind CSS v4 and avoid deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. - -### CSS-First Configuration - -In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - - -```css -@theme { - --color-brand: oklch(0.72 0.11 178); -} -``` - -### Import Syntax - -In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - - -```diff -- @tailwind base; -- @tailwind components; -- @tailwind utilities; -+ @import "tailwindcss"; -``` - -### Replaced Utilities - -Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. - -| Deprecated | Replacement | -|------------|-------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - -## Spacing - -Use `gap` utilities instead of margins for spacing between siblings: - - -```html -
-
Item 1
-
Item 2
-
-``` - -## Dark Mode - -If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - - -```html -
- Content adapts to color scheme -
-``` - -## Common Patterns - -### Flexbox Layout - - -```html -
-
Left content
-
Right content
-
-``` - -### Grid Layout - - -```html -
-
Card 1
-
Card 2
-
Card 3
-
-``` - -## Common Pitfalls - -- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) -- Using `@tailwind` directives instead of `@import "tailwindcss"` -- Trying to use `tailwind.config.js` instead of CSS `@theme` directive -- Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.claude/skills/test-honesty/SKILL.md b/.claude/skills/test-honesty/SKILL.md deleted file mode 100644 index 954eb8d6a..000000000 --- a/.claude/skills/test-honesty/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: test-honesty -description: Ensures factory, seeder, and schema alignment with production database reality ---- - -# Purpose - -Prevents schema drift between migrations, factories, and seeders. - ---- - -# 1. Schema Contract - -Every NOT NULL column defined in migrations must be supported by: - -- factory definition -- or seeder definition (only for seed data) -- or explicit DB default in migration - -This is a **schema-only rule**, not a validation rule. - ---- - -# 2. Factory Rule - -Factories MUST produce valid database rows for the schema. - -Factories are schema-aligned, not business-logic aware. - ---- - -# 3. Seeder Rule - -Seeders MUST only insert schema-valid data. - -No reliance on implicit database defaults. - ---- - -# 4. Database Parity Rule - -MySQL / MariaDB is the canonical database. - -SQLite differences are invalid for schema validation assumptions. - ---- - -# 5. Drift Triggers - -The following indicate schema drift: - -- migration changes -- factory mismatch -- seeder mismatch -- SQLSTATE constraint violations -- CI vs local DB mismatch - ---- - -# 6. Identity Rule - -Primary keys are non-deterministic. - -Tests MUST NOT rely on hardcoded IDs. - ---- - -# 7. Execution Rule (CI boundary) - -Schema validation requires: - -- migrate:fresh -- seed - -before running test suites. - -This ensures schema correctness before test execution. diff --git a/.gitignore b/.gitignore index 375301ed7..e51f2d653 100644 --- a/.gitignore +++ b/.gitignore @@ -12,11 +12,6 @@ !.env.testing.example /storage/*.key -# Laravel – Modules (ignored generated sources) -Modules/**/Controllers/ -Modules/**/Http/Controllers/ -Modules/**/Http/Requests/ - # Vite / Filament / Livewire /.vite /livewire-tmp/ @@ -40,6 +35,7 @@ package-lock.json # Pint /pint.txt +/pint_output.log # PHPUnit /.phpunit.cache @@ -78,5 +74,11 @@ package-lock.json /olddocs /.php-cs-fixer.cache *.sqlite +/audit-report.json /failures.txt /yarnpack.txt +/automation/.idea/ +/automation/vendor/ +/automation/test-honesty/vendor/ +.claude/fable5/runtime/control.json +upd.sh diff --git a/CLAUDE.md b/CLAUDE.md index 9dfe654aa..96e207591 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -255,3 +255,8 @@ No DTO layer — services accept arrays and return Eloquent models. - `Str::lower($company->search_code)` is always the URL tenant parameter - The three tenant middleware classes live at `Modules/Core/Http/Middleware/` - Panel providers live at `Modules/Core/Providers/`, not `app/Providers/` + +# LESSONS + +- Never write `*/` inside a PHP docblock (e.g. glob patterns like `Header*/Detail*`) — it terminates the comment and causes a parse error. +- When running a test suite in the background, redirect FULL output to a file — never pipe through `tail`/`head`, it destroys the failure details and forces a rerun. diff --git a/Modules/Core/Console/ReportsSyncSystemCommand.php b/Modules/Core/Console/ReportsSyncSystemCommand.php new file mode 100644 index 000000000..e016ebc4d --- /dev/null +++ b/Modules/Core/Console/ReportsSyncSystemCommand.php @@ -0,0 +1,47 @@ +error("Source directory [{$source}] does not exist."); + + return self::FAILURE; + } + + $disk = Storage::disk(ReportTemplateStorage::DISK); + $synced = 0; + + foreach (File::allFiles($source) as $file) { + $disk->put( + ReportTemplateStorage::SCOPE_SYSTEM . '/' . str_replace('\\', '/', $file->getRelativePathname()), + $file->getContents(), + ); + + $synced++; + } + + $this->info("Synced {$synced} report template file(s) into system storage."); + + return self::SUCCESS; + } +} diff --git a/Modules/Core/Database/Seeders/RolesSeeder.php b/Modules/Core/Database/Seeders/RolesSeeder.php index 452f13259..03d996fb3 100644 --- a/Modules/Core/Database/Seeders/RolesSeeder.php +++ b/Modules/Core/Database/Seeders/RolesSeeder.php @@ -120,6 +120,7 @@ function ($p) use ($customerResources) { $isBasicAction = str_starts_with($p, 'view-') || str_starts_with($p, 'create-') || str_starts_with($p, 'edit-') + || str_starts_with($p, 'delete-') || str_starts_with($p, 'export-') || str_starts_with($p, 'duplicate-'); $isCustomerResource = (bool) array_filter( diff --git a/Modules/Core/Enums/ReportBand.php b/Modules/Core/Enums/ReportBand.php index 90e149cc3..de3747a93 100644 --- a/Modules/Core/Enums/ReportBand.php +++ b/Modules/Core/Enums/ReportBand.php @@ -10,6 +10,20 @@ enum ReportBand: string case GROUP_HEADER = 'group_header'; case HEADER = 'header'; + /** + * Get all bands in document order (header first, footer last). + * + * @return array + */ + public static function ordered(): array + { + $bands = self::cases(); + + usort($bands, fn (self $a, self $b): int => $a->getOrder() <=> $b->getOrder()); + + return $bands; + } + /** * Get the display label for the band. */ diff --git a/Modules/Core/Enums/ReportBlockWidth.php b/Modules/Core/Enums/ReportBlockWidth.php new file mode 100644 index 000000000..c5ff65da5 --- /dev/null +++ b/Modules/Core/Enums/ReportBlockWidth.php @@ -0,0 +1,24 @@ + 4, + self::HALF => 6, + self::TWO_THIRDS => 8, + self::FULL => 12, + }; + } +} diff --git a/Modules/Core/Enums/ReportTemplateType.php b/Modules/Core/Enums/ReportTemplateType.php new file mode 100644 index 000000000..fa5f06349 --- /dev/null +++ b/Modules/Core/Enums/ReportTemplateType.php @@ -0,0 +1,32 @@ + trans('ip.invoice'), + self::QUOTE => trans('ip.quote'), + }; + } + + public function color(): string + { + return match ($this) { + self::INVOICE => 'success', + self::QUOTE => 'info', + }; + } +} diff --git a/Modules/Core/Filament/Admin/Pages/ReportBuilder.php b/Modules/Core/Filament/Admin/Pages/ReportBuilder.php new file mode 100644 index 000000000..3e9935e5d --- /dev/null +++ b/Modules/Core/Filament/Admin/Pages/ReportBuilder.php @@ -0,0 +1,18 @@ +user()?->hasAnyRole([ + ...UserRole::elevated(), + UserRole::CUSTOMER_ADMIN->value, + ]) ?? false; + } + + public function managesSystemScope(): bool + { + return false; + } + + public function listPage(): string + { + return ReportTemplates::class; + } +} diff --git a/Modules/Core/Filament/Company/Pages/ReportTemplates.php b/Modules/Core/Filament/Company/Pages/ReportTemplates.php new file mode 100644 index 000000000..1ca419230 --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/ReportTemplates.php @@ -0,0 +1,27 @@ +user()?->hasAnyRole([ + ...UserRole::elevated(), + UserRole::CUSTOMER_ADMIN->value, + ]) ?? false; + } + + public function managesSystemScope(): bool + { + return false; + } + + public function builderPage(): string + { + return ReportBuilder::class; + } +} diff --git a/Modules/Core/Filament/Pages/Reports/BaseReportBuilderPage.php b/Modules/Core/Filament/Pages/Reports/BaseReportBuilderPage.php new file mode 100644 index 000000000..a209045cc --- /dev/null +++ b/Modules/Core/Filament/Pages/Reports/BaseReportBuilderPage.php @@ -0,0 +1,285 @@ +managesSystemScope() && $scope !== ReportTemplateStorage::SCOPE_SYSTEM) { + abort(404); + } + + $template = app(ReportTemplateStorage::class)->load($scope, $slug, $templateType); + abort_if($template === null, 404); + + $this->scope = $scope; + $this->type = $type; + $this->templateSlug = $slug; + $this->manifest = $template['manifest']; + + $bands = []; + + foreach (ReportBand::ordered() as $band) { + $bands[$band->value] = MasonDocumentConverter::toMasonState($template['bands'][$band->value] ?? []); + } + + $this->form->fill(['bands' => $bands]); + } + + public function getTitle(): string + { + return trans('ip.report_builder') . ': ' . ($this->manifest['name'] ?? $this->templateSlug); + } + + public function form(Schema $schema): Schema + { + $fields = []; + + foreach (ReportBand::ordered() as $band) { + $fields[] = Mason::make('bands.' . $band->value) + ->label($band->getLabel()) + ->bricks(ReportBricksCollection::forBand($band)) + ->disabled( ! $this->canSave()); + } + + return $schema->components($fields)->statePath('data'); + } + + public function canSave(): bool + { + return $this->managesSystemScope() + ? $this->scope === ReportTemplateStorage::SCOPE_SYSTEM + : $this->scope === ReportTemplateStorage::SCOPE_COMPANY; + } + + public function save(): void + { + abort_unless($this->canSave(), 403); + + $state = $this->form->getState(); + $bands = []; + + foreach (ReportBand::ordered() as $band) { + $bands[$band->value] = MasonDocumentConverter::toBandEntries($state['bands'][$band->value] ?? []); + } + + app(ReportTemplateStorage::class)->save( + $this->scope, + $this->templateSlug, + $this->manifest, + $bands, + ReportTemplateType::tryFrom($this->type), + ); + + Notification::make()->title(trans('ip.template_saved'))->success()->send(); + } + + public function previewAction(): Action + { + return Action::make('preview') + ->label(trans('ip.report_preview')) + ->icon('heroicon-o-eye') + ->modalHeading(trans('ip.report_preview')) + ->modalSubmitAction(false) + ->modalCancelActionLabel(trans('ip.close')) + ->slideOver() + ->modalContent(fn (): HtmlString => new HtmlString($this->renderPreviewHtml())); + } + + public function moveBrickAction(): Action + { + return Action::make('moveBrick') + ->label(trans('ip.move_to_band')) + ->icon('heroicon-o-arrows-up-down') + ->visible(fn (): bool => $this->canSave()) + ->schema([ + Select::make('from_band') + ->label(trans('ip.from_band')) + ->options($this->bandOptions()) + ->required() + ->live(), + Select::make('position') + ->label(trans('ip.brick')) + ->options(function (\Filament\Schemas\Components\Utilities\Get $get): array { + return $this->brickOptionsForBand((string) $get('from_band')); + }) + ->required() + ->live(), + Select::make('to_band') + ->label(trans('ip.to_band')) + ->options(function (\Filament\Schemas\Components\Utilities\Get $get): array { + return $this->targetBandOptions((string) $get('from_band'), $get('position')); + }) + ->required(), + ]) + ->action(function (array $data): void { + $this->moveBrick((string) $data['from_band'], (int) $data['position'], (string) $data['to_band']); + }); + } + + public function moveBrick(string $fromBand, int $position, string $toBand): void + { + abort_unless($this->canSave(), 403); + + $source = $this->data['bands'][$fromBand] ?? []; + + if ($fromBand === $toBand || ! isset($source[$position])) { + return; + } + + $node = $source[$position]; + $brickClass = ReportBricksCollection::findById((string) ($node['attrs']['id'] ?? '')); + $target = ReportBand::tryFrom($toBand); + + if ($brickClass === null || $target === null || ! in_array($target, $brickClass::allowedBands(), true)) { + Notification::make()->title(trans('ip.brick_not_allowed_in_band'))->danger()->send(); + + return; + } + + array_splice($source, $position, 1); + + $this->data['bands'][$fromBand] = array_values($source); + $this->data['bands'][$toBand][] = $node; + $this->data['bands'][$toBand] = array_values($this->data['bands'][$toBand]); + + Notification::make()->title(trans('ip.brick_moved'))->success()->send(); + } + + protected function getHeaderActions(): array + { + return [ + $this->previewAction(), + $this->moveBrickAction(), + Action::make('save') + ->label(trans('ip.save')) + ->visible(fn (): bool => $this->canSave()) + ->action('save'), + ]; + } + + protected function renderPreviewHtml(): string + { + $html = ''; + + foreach (ReportBand::ordered() as $band) { + foreach (MasonDocumentConverter::toBandEntries($this->data['bands'][$band->value] ?? []) as $entry) { + $brickClass = ReportBricksCollection::findById($entry['brick']); + + if ($brickClass === null) { + continue; + } + + $html .= (string) $brickClass::toPreviewHtml($entry['config']); + } + } + + return $html; + } + + /** + * @return array + */ + protected function bandOptions(): array + { + $options = []; + + foreach (ReportBand::ordered() as $band) { + $options[$band->value] = $band->getLabel(); + } + + return $options; + } + + /** + * @return array + */ + protected function brickOptionsForBand(string $bandValue): array + { + $options = []; + + foreach ($this->data['bands'][$bandValue] ?? [] as $index => $node) { + $brickClass = ReportBricksCollection::findById((string) ($node['attrs']['id'] ?? '')); + + if ($brickClass !== null) { + $options[$index] = ($index + 1) . '. ' . $brickClass::getLabel(); + } + } + + return $options; + } + + /** + * @return array + */ + protected function targetBandOptions(string $fromBand, mixed $position): array + { + $node = $this->data['bands'][$fromBand][(int) $position] ?? null; + $brickClass = $node ? ReportBricksCollection::findById((string) ($node['attrs']['id'] ?? '')) : null; + + if ($brickClass === null) { + return []; + } + + $options = []; + + foreach ($brickClass::allowedBands() as $band) { + if ($band->value !== $fromBand) { + $options[$band->value] = $band->getLabel(); + } + } + + return $options; + } +} diff --git a/Modules/Core/Filament/Pages/Reports/BaseReportTemplatesPage.php b/Modules/Core/Filament/Pages/Reports/BaseReportTemplatesPage.php new file mode 100644 index 000000000..13b6e0972 --- /dev/null +++ b/Modules/Core/Filament/Pages/Reports/BaseReportTemplatesPage.php @@ -0,0 +1,164 @@ + + */ + public function getTemplates(): array + { + $storage = $this->storage(); + $templates = []; + + foreach ($storage->listSystem() as $template) { + $template['editable'] = $this->managesSystemScope(); + $templates[] = $template; + } + + if ( ! $this->managesSystemScope()) { + foreach ($storage->listCompany() as $template) { + $template['editable'] = true; + $templates[] = $template; + } + } + + return $templates; + } + + public function builderUrl(array $template): ?string + { + if ( ! $template['editable']) { + return null; + } + + return $this->builderPage()::getUrl([ + 'scope' => $template['scope'], + 'type' => $template['type'], + 'slug' => $template['slug'], + ]); + } + + public function cloneAction(): Action + { + return Action::make('clone') + ->label(trans('ip.clone')) + ->icon('heroicon-o-document-duplicate') + ->schema([ + TextInput::make('name') + ->label(trans('ip.name')) + ->required() + ->maxLength(100), + ]) + ->action(function (array $arguments, array $data): void { + $clone = $this->storage()->clone( + (string) $arguments['scope'], + (string) $arguments['slug'], + (string) $data['name'], + ReportTemplateType::tryFrom((string) $arguments['type']), + $this->managesSystemScope() ? ReportTemplateStorage::SCOPE_SYSTEM : ReportTemplateStorage::SCOPE_COMPANY, + ); + + Notification::make() + ->title(trans('ip.template_cloned')) + ->body($clone['manifest']['name']) + ->success() + ->send(); + }); + } + + public function renameAction(): Action + { + return Action::make('rename') + ->label(trans('ip.rename')) + ->icon('heroicon-o-pencil-square') + ->fillForm(fn (array $arguments): array => ['name' => $arguments['name'] ?? '']) + ->schema([ + TextInput::make('name') + ->label(trans('ip.name')) + ->required() + ->maxLength(100), + ]) + ->action(function (array $arguments, array $data): void { + $this->storage()->rename( + (string) $arguments['scope'], + (string) $arguments['slug'], + (string) $data['name'], + ReportTemplateType::tryFrom((string) $arguments['type']), + ); + + Notification::make()->title(trans('ip.template_renamed'))->success()->send(); + }); + } + + public function deleteAction(): Action + { + return Action::make('delete') + ->label(trans('ip.delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(function (array $arguments): void { + $this->storage()->delete( + (string) $arguments['scope'], + (string) $arguments['slug'], + ReportTemplateType::tryFrom((string) $arguments['type']), + ); + + Notification::make()->title(trans('ip.template_deleted'))->success()->send(); + }); + } + + public function canModify(array $template): bool + { + if ( ! $template['editable']) { + return false; + } + + return ! ($template['scope'] === ReportTemplateStorage::SCOPE_SYSTEM && $template['slug'] === 'default'); + } + + protected function storage(): ReportTemplateStorage + { + return app(ReportTemplateStorage::class); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php b/Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php new file mode 100644 index 000000000..a7ac30804 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailCustomerAgingBrick.php @@ -0,0 +1,98 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.customer_aging_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-customer-aging.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-customer-aging.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_customer_aging')) + ->modalHeading(trans('ip.customer_aging_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_invoice_number') + ->label(trans('ip.show_invoice_number')) + ->default(true), + Checkbox::make('show_invoice_date') + ->label(trans('ip.show_invoice_date')) + ->default(true), + Checkbox::make('show_due_date') + ->label(trans('ip.show_due_date')) + ->default(true), + Checkbox::make('show_current') + ->label(trans('ip.show_current')) + ->default(true), + Checkbox::make('show_30_days') + ->label(trans('ip.show_30_days')) + ->default(true), + Checkbox::make('show_60_days') + ->label(trans('ip.show_60_days')) + ->default(true), + Checkbox::make('show_90_days') + ->label(trans('ip.show_90_days')) + ->default(true), + Checkbox::make('show_over_90_days') + ->label(trans('ip.show_over_90_days')) + ->default(true), + Checkbox::make('show_total_due') + ->label(trans('ip.show_total_due')) + ->default(true), + Checkbox::make('highlight_overdue') + ->label(trans('ip.highlight_overdue')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailExpenseBrick.php b/Modules/Core/Mason/Bricks/DetailExpenseBrick.php new file mode 100644 index 000000000..8fc5198d9 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailExpenseBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.expense_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-expense.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-expense.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_expense_details')) + ->modalHeading(trans('ip.expense_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_expense_number') + ->label(trans('ip.show_expense_number')) + ->default(true), + Checkbox::make('show_expense_date') + ->label(trans('ip.show_expense_date')) + ->default(true), + Checkbox::make('show_category') + ->label(trans('ip.show_category')) + ->default(true), + Checkbox::make('show_vendor') + ->label(trans('ip.show_vendor')) + ->default(false), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_amount') + ->label(trans('ip.show_amount')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php b/Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php new file mode 100644 index 000000000..a294063ec --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailInvoiceProductBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_product_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-invoice-product.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-invoice-product.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_product_details')) + ->modalHeading(trans('ip.invoice_product_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_sku') + ->label(trans('ip.show_sku')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_unit_price') + ->label(trans('ip.show_unit_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_discount') + ->label(trans('ip.show_discount')) + ->default(false), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php b/Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php new file mode 100644 index 000000000..4b4820982 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailInvoiceProjectBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_project_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-invoice-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-invoice-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_project_details')) + ->modalHeading(trans('ip.invoice_project_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_hours') + ->label(trans('ip.show_hours')) + ->default(true), + Checkbox::make('show_rate') + ->label(trans('ip.show_rate')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('group_by_project') + ->label(trans('ip.group_by_project')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailItemsBrick.php b/Modules/Core/Mason/Bricks/DetailItemsBrick.php new file mode 100644 index 000000000..7c99247c7 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailItemsBrick.php @@ -0,0 +1,83 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.line_items_table'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-items.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-items.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_line_items')) + ->modalHeading(trans('ip.line_items_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_price') + ->label(trans('ip.show_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php b/Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php new file mode 100644 index 000000000..6385cb9d1 --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailQuoteProductBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_product_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-quote-product.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-quote-product.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_product_details')) + ->modalHeading(trans('ip.quote_product_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_sku') + ->label(trans('ip.show_sku')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_unit_price') + ->label(trans('ip.show_unit_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_discount') + ->label(trans('ip.show_discount')) + ->default(false), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php b/Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php new file mode 100644 index 000000000..800e7ad3d --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailQuoteProjectBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_project_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-quote-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-quote-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_project_details')) + ->modalHeading(trans('ip.quote_project_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_hours') + ->label(trans('ip.show_hours')) + ->default(true), + Checkbox::make('show_rate') + ->label(trans('ip.show_rate')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('group_by_project') + ->label(trans('ip.group_by_project')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/DetailTasksBrick.php b/Modules/Core/Mason/Bricks/DetailTasksBrick.php new file mode 100644 index 000000000..8c89bddab --- /dev/null +++ b/Modules/Core/Mason/Bricks/DetailTasksBrick.php @@ -0,0 +1,92 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.tasks_table'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.detail-tasks.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.detail-tasks.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_tasks')) + ->modalHeading(trans('ip.tasks_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_task_number') + ->label(trans('ip.show_task_number')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_due_at') + ->label(trans('ip.show_due_at')) + ->default(false), + Checkbox::make('show_task_price') + ->label(trans('ip.show_task_price')) + ->default(true), + Checkbox::make('show_task_status') + ->label(trans('ip.show_task_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(6) + ->maxValue(12), + Select::make('header_style') + ->label(trans('ip.header_style')) + ->options([ + 'normal' => trans('ip.normal'), + 'bold' => trans('ip.bold'), + 'italic' => trans('ip.italic'), + ]) + ->default('bold'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterNotesBrick.php b/Modules/Core/Mason/Bricks/FooterNotesBrick.php new file mode 100644 index 000000000..097e3f312 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterNotesBrick.php @@ -0,0 +1,75 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.footer'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-notes.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-notes.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_notes')) + ->modalHeading(trans('ip.notes_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + RichEditor::make('footer_content') + ->label(trans('ip.footer_content')) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(8) + ->minValue(6) + ->maxValue(12), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterSummaryBrick.php b/Modules/Core/Mason/Bricks/FooterSummaryBrick.php new file mode 100644 index 000000000..55b7fd890 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterSummaryBrick.php @@ -0,0 +1,75 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.summary'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-summary.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-summary.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_summary')) + ->modalHeading(trans('ip.summary_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + RichEditor::make('summary_content') + ->label(trans('ip.summary_content')) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(6) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterTermsBrick.php b/Modules/Core/Mason/Bricks/FooterTermsBrick.php new file mode 100644 index 000000000..cc887bf70 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterTermsBrick.php @@ -0,0 +1,75 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.terms_conditions'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-terms.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-terms.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_terms')) + ->modalHeading(trans('ip.terms_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + RichEditor::make('terms_content') + ->label(trans('ip.terms_content')) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(8) + ->minValue(6) + ->maxValue(12), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/FooterTotalsBrick.php b/Modules/Core/Mason/Bricks/FooterTotalsBrick.php new file mode 100644 index 000000000..7c45ef2d1 --- /dev/null +++ b/Modules/Core/Mason/Bricks/FooterTotalsBrick.php @@ -0,0 +1,92 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.totals_section'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.footer-totals.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.footer-totals.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_totals')) + ->modalHeading(trans('ip.totals_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_subtotal') + ->label(trans('ip.show_subtotal')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('show_paid') + ->label(trans('ip.show_paid')) + ->default(false), + Checkbox::make('show_balance') + ->label(trans('ip.show_balance')) + ->default(false), + Checkbox::make('highlight_total') + ->label(trans('ip.highlight_total')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderClientBrick.php b/Modules/Core/Mason/Bricks/HeaderClientBrick.php new file mode 100644 index 000000000..3649420d3 --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderClientBrick.php @@ -0,0 +1,83 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.client_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-client.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-client.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_client_header')) + ->modalHeading(trans('ip.client_header_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_phone') + ->label(trans('ip.show_phone')) + ->default(true), + Checkbox::make('show_email') + ->label(trans('ip.show_email')) + ->default(true), + Checkbox::make('show_address') + ->label(trans('ip.show_address')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderCompanyBrick.php b/Modules/Core/Mason/Bricks/HeaderCompanyBrick.php new file mode 100644 index 000000000..2ec0e61cc --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderCompanyBrick.php @@ -0,0 +1,93 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.company_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-company.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-company.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_company_header')) + ->modalHeading(trans('ip.company_header_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_vat_id') + ->label(trans('ip.show_vat_id')) + ->default(true), + Checkbox::make('show_phone') + ->label(trans('ip.show_phone')) + ->default(true), + Checkbox::make('show_email') + ->label(trans('ip.show_email')) + ->default(true), + Checkbox::make('show_address') + ->label(trans('ip.show_address')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('font_weight') + ->label(trans('ip.font_weight')) + ->options([ + 'normal' => trans('ip.font_weight_normal'), + 'bold' => trans('ip.font_weight_bold'), + ]) + ->default('bold'), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('left'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php b/Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php new file mode 100644 index 000000000..9d2d7bc46 --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderInvoiceMetaBrick.php @@ -0,0 +1,86 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_metadata'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-invoice-meta.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-invoice-meta.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_metadata')) + ->modalHeading(trans('ip.invoice_metadata_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_invoice_number') + ->label(trans('ip.show_invoice_number')) + ->default(true), + Checkbox::make('show_invoice_date') + ->label(trans('ip.show_invoice_date')) + ->default(true), + Checkbox::make('show_due_date') + ->label(trans('ip.show_due_date')) + ->default(true), + Checkbox::make('show_po_number') + ->label(trans('ip.show_po_number')) + ->default(false), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderProjectBrick.php b/Modules/Core/Mason/Bricks/HeaderProjectBrick.php new file mode 100644 index 000000000..c22c1ecaf --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderProjectBrick.php @@ -0,0 +1,89 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.project_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_project')) + ->modalHeading(trans('ip.project_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_project_number') + ->label(trans('ip.show_project_number')) + ->default(true), + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_start_date') + ->label(trans('ip.show_start_date')) + ->default(true), + Checkbox::make('show_end_date') + ->label(trans('ip.show_end_date')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(6) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('left'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php b/Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php new file mode 100644 index 000000000..034288dff --- /dev/null +++ b/Modules/Core/Mason/Bricks/HeaderQuoteMetaBrick.php @@ -0,0 +1,86 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_metadata'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.header-quote-meta.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.header-quote-meta.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_meta')) + ->modalHeading(trans('ip.quote_meta_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Checkbox::make('show_quote_number') + ->label(trans('ip.show_quote_number')) + ->default(true), + Checkbox::make('show_quoted_at') + ->label(trans('ip.show_quoted_at')) + ->default(true), + Checkbox::make('show_expires_at') + ->label(trans('ip.show_expires_at')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(6) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/Mason/Bricks/PageBreakBrick.php b/Modules/Core/Mason/Bricks/PageBreakBrick.php new file mode 100644 index 000000000..a2e237c3a --- /dev/null +++ b/Modules/Core/Mason/Bricks/PageBreakBrick.php @@ -0,0 +1,58 @@ +'); + } + + public static function allowedBands(): array + { + return ReportBand::cases(); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.page_break'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.page-break.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.page-break.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->modalHidden(); + } +} diff --git a/Modules/Core/Mason/Bricks/SpacerBrick.php b/Modules/Core/Mason/Bricks/SpacerBrick.php new file mode 100644 index 000000000..feccb43dd --- /dev/null +++ b/Modules/Core/Mason/Bricks/SpacerBrick.php @@ -0,0 +1,70 @@ +'); + } + + public static function allowedBands(): array + { + return ReportBand::cases(); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.spacer'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('mason.bricks.spacer.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('mason.bricks.spacer.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_spacer')) + ->modalHeading(trans('ip.spacer_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + TextInput::make('height') + ->label(trans('ip.spacer_height')) + ->numeric() + ->default(20) + ->minValue(1) + ->maxValue(500), + ]); + } +} diff --git a/Modules/Core/Mason/MasonDocumentConverter.php b/Modules/Core/Mason/MasonDocumentConverter.php new file mode 100644 index 000000000..113a7025b --- /dev/null +++ b/Modules/Core/Mason/MasonDocumentConverter.php @@ -0,0 +1,90 @@ + $entries + */ + public static function toMasonState(array $entries): array + { + $state = []; + + foreach ($entries as $entry) { + $brickClass = ReportBricksCollection::findById((string) ($entry['brick'] ?? '')); + + if ($brickClass === null) { + continue; + } + + $config = is_array($entry['config'] ?? null) ? $entry['config'] : []; + $config[self::WIDTH_KEY] = $entry['width'] ?? ReportBlockWidth::FULL->value; + + $state[] = [ + 'type' => 'masonBrick', + 'attrs' => [ + 'id' => $brickClass::getId(), + 'config' => $config, + 'label' => $brickClass::getLabel(), + 'preview' => base64_encode((string) $brickClass::toPreviewHtml($config)), + ], + ]; + } + + return $state; + } + + /** + * Mason editor state → band entries (width lifted out of config). + * + * @return array + */ + public static function toBandEntries(mixed $state): array + { + if ( ! is_array($state)) { + return []; + } + + if (array_key_exists('content', $state)) { + $state = $state['content']; + } + + $entries = []; + + foreach ($state as $node) { + if ( ! is_array($node) || ($node['type'] ?? null) !== 'masonBrick') { + continue; + } + + $attrs = $node['attrs'] ?? []; + $config = is_array($attrs['config'] ?? null) ? $attrs['config'] : []; + + $width = ReportBlockWidth::tryFrom((string) ($config[self::WIDTH_KEY] ?? '')) ?? ReportBlockWidth::FULL; + unset($config[self::WIDTH_KEY]); + + $entries[] = [ + 'brick' => (string) ($attrs['id'] ?? ''), + 'width' => $width->value, + 'config' => $config, + ]; + } + + return $entries; + } +} diff --git a/Modules/Core/Mason/ReportBrick.php b/Modules/Core/Mason/ReportBrick.php new file mode 100644 index 000000000..9a8c2e367 --- /dev/null +++ b/Modules/Core/Mason/ReportBrick.php @@ -0,0 +1,83 @@ +> + */ + protected static array $configKeysCache = []; + + /** + * The bands this brick may be placed in. + * + * Defaults are inferred from the class name prefix (Header, Detail, Footer); + * override for bricks that do not follow the prefix convention. + * + * @return array + */ + public static function allowedBands(): array + { + $basename = class_basename(static::class); + + return match (true) { + str_starts_with($basename, 'Header') => [ReportBand::HEADER, ReportBand::GROUP_HEADER], + str_starts_with($basename, 'Detail') => [ReportBand::DETAILS], + str_starts_with($basename, 'Footer') => [ReportBand::GROUP_FOOTER, ReportBand::FOOTER], + default => ReportBand::cases(), + }; + } + + /** + * The config keys this brick accepts, derived from its configure action + * schema. Used to filter persisted config against the brick's own schema. + * + * @return array + */ + public static function configKeys(): array + { + if (isset(static::$configKeysCache[static::class])) { + return static::$configKeysCache[static::class]; + } + + $action = static::configureBrickAction(Action::make('configure')); + + $property = new ReflectionProperty(Action::class, 'schema'); + $schema = $property->getValue($action); + + $keys = []; + + if (is_array($schema)) { + foreach ($schema as $component) { + if (method_exists($component, 'getName')) { + $keys[] = $component->getName(); + } + } + } + + return static::$configKeysCache[static::class] = $keys; + } + + /** + * Filter a persisted config array down to the keys this brick declares. + */ + public static function filterConfig(array $config): array + { + return array_intersect_key($config, array_flip(static::configKeys())); + } +} diff --git a/Modules/Core/Mason/ReportBricksCollection.php b/Modules/Core/Mason/ReportBricksCollection.php new file mode 100644 index 000000000..6dc35c6f2 --- /dev/null +++ b/Modules/Core/Mason/ReportBricksCollection.php @@ -0,0 +1,140 @@ + + */ + public static function all(): array + { + return [ + ...self::header(), + ...self::detail(), + ...self::footer(), + ...self::utility(), + ]; + } + + /** + * Get utility bricks allowed in every band. + * + * @return array + */ + public static function utility(): array + { + return [ + PageBreakBrick::class, + SpacerBrick::class, + ]; + } + + /** + * Get the bricks allowed in the given band. + * + * @return array + */ + public static function forBand(ReportBand $band): array + { + return array_values(array_filter( + self::all(), + fn (string $brick): bool => in_array($band, $brick::allowedBands(), true), + )); + } + + /** + * Find a brick class by its brick id. + * + * @return class-string|null + */ + public static function findById(string $id): ?string + { + foreach (self::all() as $brick) { + if ($brick::getId() === $id) { + return $brick; + } + } + + return null; + } + + /** + * Get header section bricks. + * + * @return array + */ + public static function header(): array + { + return [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + HeaderQuoteMetaBrick::class, + HeaderProjectBrick::class, + ]; + } + + /** + * Get detail section bricks. + * + * @return array + */ + public static function detail(): array + { + return [ + DetailItemsBrick::class, + DetailTasksBrick::class, + DetailInvoiceProductBrick::class, + DetailInvoiceProjectBrick::class, + DetailQuoteProductBrick::class, + DetailQuoteProjectBrick::class, + DetailCustomerAgingBrick::class, + DetailExpenseBrick::class, + ]; + } + + /** + * Get footer section bricks. + * + * @return array + */ + public static function footer(): array + { + return [ + FooterTotalsBrick::class, + FooterNotesBrick::class, + FooterTermsBrick::class, + FooterSummaryBrick::class, + ]; + } +} diff --git a/Modules/Core/Models/Company.php b/Modules/Core/Models/Company.php index b4159370c..0eac41c2d 100644 --- a/Modules/Core/Models/Company.php +++ b/Modules/Core/Models/Company.php @@ -132,7 +132,7 @@ public function shippingAddress() public function communications(): MorphMany { - return $this->morphMany(Communication::class, 'communicable'); + return $this->morphMany(Communication::class, 'communicationable'); } public function companyUsers(): BelongsToMany diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php index 9f4917992..2a86d54e2 100644 --- a/Modules/Core/Providers/AdminPanelProvider.php +++ b/Modules/Core/Providers/AdminPanelProvider.php @@ -22,6 +22,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Core\Filament\Admin\Pages\Dashboard; +use Modules\Core\Filament\Admin\Pages\ReportTemplates; use Modules\Core\Filament\Admin\Pages\RolePermissionsPage; use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource; use Modules\Core\Filament\Admin\Resources\EmailTemplates\EmailTemplateResource; @@ -133,6 +134,10 @@ public function panel(Panel $panel): Panel ->items([ ...TaxRateResource::getNavigationItems(), ]), + NavigationGroup::make(trans('ip.report_templates')) + ->items([ + ...ReportTemplates::getNavigationItems(), + ]), /*NavigationGroup::make('System Settings') ->icon('heroicon-o-cog-8-tooth') diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index de6870cf1..50c442ee8 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -23,6 +23,8 @@ use Modules\Clients\Filament\Company\Resources\Contacts\ContactResource; use Modules\Clients\Filament\Company\Resources\Relations\RelationResource; use Modules\Core\Filament\Company\Pages\Dashboard; +use Modules\Core\Filament\Company\Pages\ReportBuilder; +use Modules\Core\Filament\Company\Pages\ReportTemplates; use Modules\Core\Filament\Pages\Auth\EditProfile; use Modules\Core\Filament\Pages\Auth\Login; use Modules\Core\Http\Middleware\ConfigureTenant; @@ -173,6 +175,8 @@ public function panel(Panel $panel): Panel ->discoverWidgets(in: app_path('Filament/Company/Widgets'), for: 'App\Filament\Company\Widgets') ->pages([ Dashboard::class, + ReportTemplates::class, + ReportBuilder::class, ]) ->widgets([ RecentQuotesWidget::class, @@ -224,6 +228,11 @@ public function panel(Panel $panel): Panel ...PaymentResource::getNavigationItems(), ]), + NavigationGroup::make(trans('ip.report_templates')) + ->items([ + ...ReportTemplates::getNavigationItems(), + ]), + NavigationGroup::make('Resources') //->icon('heroicon-o-archive-box') ->items([ diff --git a/Modules/Core/Providers/CoreServiceProvider.php b/Modules/Core/Providers/CoreServiceProvider.php index 85eab6ac8..472977aba 100644 --- a/Modules/Core/Providers/CoreServiceProvider.php +++ b/Modules/Core/Providers/CoreServiceProvider.php @@ -71,7 +71,9 @@ public function provides(): array protected function registerCommands(): void { - $this->commands([]); + $this->commands([ + \Modules\Core\Console\ReportsSyncSystemCommand::class, + ]); } protected function registerCommandSchedules(): void diff --git a/Modules/Core/Services/PdfGenerationService.php b/Modules/Core/Services/PdfGenerationService.php index 3525abe47..a52ae2b6a 100644 --- a/Modules/Core/Services/PdfGenerationService.php +++ b/Modules/Core/Services/PdfGenerationService.php @@ -2,4 +2,114 @@ namespace Modules\Core\Services; -class PdfGenerationService {} +use Modules\Core\Enums\ReportTemplateType; +use Modules\Core\Support\PDF\PDFFactory; +use Modules\Invoices\Models\Invoice; +use Modules\Quotes\Models\Quote; +use RuntimeException; +use Throwable; + +/** + * Turns an Invoice or Quote into a PDF via its report template. + * + * Template resolution chain: the document's own template slug, then the + * company default (companies.invoice_template / companies.quote_template), + * then the shipped system default. Slugs are looked up in the company + * scope first so clones shadow system templates of the same name. + */ +class PdfGenerationService +{ + public function __construct( + protected ReportTemplateStorage $storage, + protected ReportRenderer $renderer, + protected ReportDataMapper $mapper, + ) {} + + public function renderInvoiceHtml(Invoice $invoice): string + { + return $this->renderer->render( + $this->resolveTemplate($invoice), + $this->mapper->forInvoice($invoice), + ); + } + + public function renderQuoteHtml(Quote $quote): string + { + return $this->renderer->render( + $this->resolveTemplate($quote), + $this->mapper->forQuote($quote), + ); + } + + public function invoicePdf(Invoice $invoice): string + { + return PDFFactory::create()->getOutput($this->renderInvoiceHtml($invoice)); + } + + public function quotePdf(Quote $quote): string + { + return PDFFactory::create()->getOutput($this->renderQuoteHtml($quote)); + } + + public function downloadInvoice(Invoice $invoice) + { + return response($this->invoicePdf($invoice)) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'attachment; filename="' . $this->filename('invoice', (string) $invoice->invoice_number) . '"'); + } + + public function downloadQuote(Quote $quote) + { + return response($this->quotePdf($quote)) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'attachment; filename="' . $this->filename('quote', (string) $quote->quote_number) . '"'); + } + + /** + * @return array{manifest: array, bands: array} + */ + public function resolveTemplate(Invoice | Quote $document): array + { + $type = $document instanceof Invoice ? ReportTemplateType::INVOICE : ReportTemplateType::QUOTE; + + $companyDefault = $document instanceof Invoice + ? $document->company?->invoice_template + : $document->company?->quote_template; + + foreach (array_filter([(string) $document->template, (string) $companyDefault, 'default']) as $slug) { + if (($template = $this->loadBySlug($slug, $type)) !== null) { + return $template; + } + } + + throw new RuntimeException( + "No report template found for {$type->value} documents. Run \"php artisan reports:sync-system\".", + ); + } + + protected function loadBySlug(string $slug, ReportTemplateType $type): ?array + { + try { + $template = $this->storage->load(ReportTemplateStorage::SCOPE_COMPANY, $slug); + } catch (Throwable) { + $template = null; + } + + if ($template !== null && ($template['manifest']['type'] ?? null) === $type->value) { + return $template; + } + + try { + return $this->storage->load(ReportTemplateStorage::SCOPE_SYSTEM, $slug, $type); + } catch (Throwable) { + return null; + } + } + + protected function filename(string $prefix, string $number): string + { + $number = preg_replace('/[^A-Za-z0-9\-_]/', '-', $number) ?: 'document'; + + return $prefix . '-' . $number . '.pdf'; + } +} diff --git a/Modules/Core/Services/ReportDataMapper.php b/Modules/Core/Services/ReportDataMapper.php new file mode 100644 index 000000000..6351191ff --- /dev/null +++ b/Modules/Core/Services/ReportDataMapper.php @@ -0,0 +1,151 @@ +loadMissing(['company', 'customer.addresses', 'customer.communications', 'invoiceItems', 'payments']); + + $paid = (float) $invoice->payments->sum('payment_amount'); + + return [ + 'company' => $this->companyData($invoice->company), + 'client' => $this->clientData($invoice->customer), + 'invoice' => [ + 'number' => (string) $invoice->invoice_number, + 'date' => $invoice->invoiced_at?->format('Y-m-d') ?? '', + 'due_date' => $invoice->invoice_due_at?->format('Y-m-d') ?? '', + 'po_number' => '', + 'status' => $invoice->invoice_status?->value ?? '', + ], + 'items' => $invoice->invoiceItems->map(fn ($item): array => $this->itemData($item))->all(), + 'totals' => [ + 'subtotal' => $this->money($invoice->invoice_item_subtotal), + 'tax' => $this->money($invoice->invoice_tax_total), + 'total' => $this->money($invoice->invoice_total), + 'paid' => $this->money($paid), + 'balance' => $this->money((float) $invoice->invoice_total - $paid), + ], + 'summary' => (string) $invoice->summary, + 'terms' => (string) $invoice->terms, + 'footer' => (string) $invoice->footer, + ]; + } + + public function forQuote(Quote $quote): array + { + $quote->loadMissing(['company', 'prospect.addresses', 'prospect.communications', 'quoteItems']); + + return [ + 'company' => $this->companyData($quote->company), + 'client' => $this->clientData($quote->prospect), + 'quote' => [ + 'quote_number' => (string) $quote->quote_number, + 'quoted_at' => $quote->quoted_at?->format('Y-m-d') ?? '', + 'quote_expires_at' => $quote->quote_expires_at?->format('Y-m-d') ?? '', + 'quote_status' => $quote->quote_status?->value ?? '', + ], + 'items' => $quote->quoteItems->map(fn ($item): array => $this->itemData($item))->all(), + 'totals' => [ + 'subtotal' => $this->money($quote->quote_item_subtotal), + 'tax' => $this->money($quote->quote_tax_total), + 'total' => $this->money($quote->quote_total), + 'paid' => $this->money(0), + 'balance' => $this->money($quote->quote_total), + ], + 'summary' => (string) $quote->summary, + 'terms' => (string) $quote->terms, + 'footer' => (string) $quote->footer, + ]; + } + + protected function companyData(?Company $company): array + { + if ($company === null) { + return []; + } + + $address = $company->addresses->first(); + + return [ + 'name' => (string) $company->name, + 'vat_id' => (string) $company->vat_number, + 'address' => (string) ($address?->address_1 ?? ''), + 'city' => (string) ($address?->city ?? ''), + 'postal_code' => (string) ($address?->postal_code ?? ''), + 'phone' => $this->communication($company, 'phone'), + 'email' => $this->communication($company, 'email'), + 'logo_path' => $this->logoPath($company), + ]; + } + + protected function clientData(?Relation $client): array + { + if ($client === null) { + return []; + } + + $address = $client->addresses->first(); + + return [ + 'name' => (string) $client->company_name, + 'address' => (string) ($address?->address_1 ?? ''), + 'city' => (string) ($address?->city ?? ''), + 'postal_code' => (string) ($address?->postal_code ?? ''), + 'phone' => $this->communication($client, 'phone'), + 'email' => $this->communication($client, 'email'), + ]; + } + + protected function itemData($item): array + { + return [ + 'description' => (string) ($item->item_name ?: $item->description), + 'quantity' => (float) $item->quantity, + 'price' => $this->money($item->price), + 'tax' => $this->money($item->tax_total), + 'total' => $this->money($item->total), + ]; + } + + /** + * dompdf runs with remote fetching disabled, so the logo must resolve + * to a local file path. + */ + protected function logoPath(Company $company): string + { + if (blank($company->logo)) { + return ''; + } + + $path = Storage::disk('public')->path($company->logo); + + return is_file($path) ? $path : ''; + } + + protected function communication($model, string $type): string + { + $communication = $model->communications + ->first(fn ($entry): bool => str_contains((string) $entry->communication_type, $type)); + + return (string) ($communication?->communication_value ?? ''); + } + + protected function money(mixed $amount): string + { + return number_format((float) $amount, 2, '.', ''); + } +} diff --git a/Modules/Core/Services/ReportRenderer.php b/Modules/Core/Services/ReportRenderer.php new file mode 100644 index 000000000..365ca7459 --- /dev/null +++ b/Modules/Core/Services/ReportRenderer.php @@ -0,0 +1,169 @@ +} $template + */ + public function render(array $template, array $data): string + { + $body = ''; + + foreach (ReportBand::ordered() as $band) { + $body .= $this->renderBand($band, $template, $data); + } + + return $this->wrapDocument($body, (string) ($template['manifest']['name'] ?? 'Report')); + } + + protected function renderBand(ReportBand $band, array $template, array $data): string + { + $entries = $template['bands'][$band->value] ?? []; + + if ($entries === []) { + return ''; + } + + $style = 'width: 100%;'; + + if ($this->keepsTogether($band, $template['manifest'])) { + $style .= ' page-break-inside: avoid;'; + } + + $html = '
'; + + /* + * page-break bricks must live at block level between the row + * tables — dompdf ignores page-break CSS inside table cells. + */ + $segment = []; + + foreach ($entries as $entry) { + if (($entry['brick'] ?? null) === 'page_break') { + $html .= $this->renderRows($segment, $data); + $html .= (string) \Modules\Core\Mason\Bricks\PageBreakBrick::toHtml($entry['config'] ?? [], $data); + $segment = []; + + continue; + } + + $segment[] = $entry; + } + + $html .= $this->renderRows($segment, $data); + $html .= '
'; + + return $html; + } + + protected function renderRows(array $entries, array $data): string + { + $html = ''; + + foreach ($this->chunkIntoRows($entries) as $row) { + $html .= $this->renderRow($row, $data); + } + + return $html; + } + + /** + * Group consecutive entries into rows on a 12-column grid — dompdf + * cannot lay out floats reliably, so each row becomes a table. + * + * @return array> + */ + protected function chunkIntoRows(array $entries): array + { + $rows = []; + $row = []; + $rowWidth = 0; + + foreach ($entries as $entry) { + if ( ! is_array($entry) || ReportBricksCollection::findById((string) ($entry['brick'] ?? '')) === null) { + continue; + } + + $width = ReportBlockWidth::tryFrom((string) ($entry['width'] ?? '')) ?? ReportBlockWidth::FULL; + + if ($row !== [] && $rowWidth + $width->getGridWidth() > 12) { + $rows[] = $row; + $row = []; + $rowWidth = 0; + } + + $row[] = ['entry' => $entry, 'width' => $width]; + $rowWidth += $width->getGridWidth(); + } + + if ($row !== []) { + $rows[] = $row; + } + + return $rows; + } + + /** + * @param array $row + */ + protected function renderRow(array $row, array $data): string + { + $cells = ''; + + foreach ($row as $block) { + $brickClass = ReportBricksCollection::findById((string) $block['entry']['brick']); + $config = is_array($block['entry']['config'] ?? null) ? $block['entry']['config'] : []; + $inner = (string) $brickClass::toHtml($brickClass::filterConfig($config), $data); + $percent = (int) round($block['width']->getGridWidth() / 12 * 100); + + $cells .= '' + . $inner + . ''; + } + + return '' . $cells . '
'; + } + + protected function keepsTogether(ReportBand $band, array $manifest): bool + { + return (bool) ($manifest['band_options'][$band->value]['keep_together'] ?? false); + } + + protected function wrapDocument(string $body, string $title): string + { + return << + + + + {$this->escape($title)} + + + + {$body} + + + HTML; + } + + protected function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/Modules/Core/Services/ReportTemplateStorage.php b/Modules/Core/Services/ReportTemplateStorage.php new file mode 100644 index 000000000..acb4a3eff --- /dev/null +++ b/Modules/Core/Services/ReportTemplateStorage.php @@ -0,0 +1,372 @@ + + */ + public function listSystem(?ReportTemplateType $type = null): array + { + $templates = []; + $types = $type ? [$type->value] : ReportTemplateType::values(); + + foreach ($types as $typeValue) { + foreach (Storage::disk(self::DISK)->directories(self::SCOPE_SYSTEM . '/' . $typeValue) as $directory) { + $manifest = $this->readJson($directory . '/manifest.json'); + + if ($manifest === null) { + continue; + } + + $templates[] = [ + 'scope' => self::SCOPE_SYSTEM, + 'type' => $typeValue, + 'slug' => basename($directory), + 'manifest' => $manifest, + ]; + } + } + + return $templates; + } + + /** + * List the current company's templates, optionally filtered by type. + * + * @return array + */ + public function listCompany(?ReportTemplateType $type = null): array + { + $templates = []; + + foreach (Storage::disk(self::DISK)->directories((string) $this->companyId()) as $directory) { + $manifest = $this->readJson($directory . '/manifest.json'); + + if ($manifest === null) { + continue; + } + + if ($type !== null && ($manifest['type'] ?? null) !== $type->value) { + continue; + } + + $templates[] = [ + 'scope' => self::SCOPE_COMPANY, + 'type' => (string) ($manifest['type'] ?? ''), + 'slug' => basename($directory), + 'manifest' => $manifest, + ]; + } + + return $templates; + } + + /** + * Slug => display name options for template pickers. Company clones + * shadow system templates that share a slug, matching the resolution + * order used when rendering. + * + * @return array + */ + public function optionsForType(ReportTemplateType $type): array + { + $options = []; + + foreach ($this->listSystem($type) as $template) { + $options[$template['slug']] = (string) ($template['manifest']['name'] ?? $template['slug']); + } + + foreach ($this->listCompany($type) as $template) { + $options[$template['slug']] = (string) ($template['manifest']['name'] ?? $template['slug']); + } + + ksort($options); + + return $options; + } + + public function exists(string $scope, string $slug, ?ReportTemplateType $type = null): bool + { + return Storage::disk(self::DISK)->exists($this->path($scope, $slug, $type) . '/manifest.json'); + } + + /** + * Load a template. Bands are validated on load: unknown brick ids and + * bricks not allowed in their band are skipped, widths fall back to + * full, and configs are filtered against each brick's own schema. + * + * @return array{manifest: array, bands: array}|null + */ + public function load(string $scope, string $slug, ?ReportTemplateType $type = null): ?array + { + $base = $this->path($scope, $slug, $type); + $manifest = $this->readJson($base . '/manifest.json'); + + if ($manifest === null) { + return null; + } + + $bands = $this->readJson($base . '/bands.json') ?? []; + + return [ + 'manifest' => $manifest, + 'bands' => $this->sanitizeBands($bands), + ]; + } + + /** + * Persist a template's manifest and bands. Bands are sanitized before + * writing so only known bricks, valid widths, and schema-filtered + * configs ever reach disk. + */ + public function save(string $scope, string $slug, array $manifest, array $bands, ?ReportTemplateType $type = null): void + { + $base = $this->path($scope, $slug, $type); + $disk = Storage::disk(self::DISK); + + $disk->put($base . '/manifest.json', $this->encodeJson($manifest)); + $disk->put($base . '/bands.json', $this->encodeJson($this->sanitizeBands($bands))); + } + + /** + * Clone a template into the current company (or into the system scope). + * Cloning copies the folder and rewrites the manifest. + * + * @return array{scope: string, type: string, slug: string, manifest: array} + */ + public function clone( + string $fromScope, + string $fromSlug, + string $newName, + ?ReportTemplateType $type = null, + string $toScope = self::SCOPE_COMPANY, + ): array { + $source = $this->load($fromScope, $fromSlug, $type); + + if ($source === null) { + throw new RuntimeException("Report template [{$fromScope}/{$fromSlug}] does not exist."); + } + + $manifest = $source['manifest']; + $templateType = $type ?? ReportTemplateType::tryFrom((string) ($manifest['type'] ?? '')); + $newSlug = $this->uniqueSlug($toScope, Str::slug($newName), $templateType); + + $manifest['name'] = $newName; + $manifest['slug'] = $newSlug; + $manifest['cloned_from'] = $this->path($fromScope, $fromSlug, $type); + + $this->save($toScope, $newSlug, $manifest, $source['bands'], $templateType); + + return [ + 'scope' => $toScope, + 'type' => (string) ($manifest['type'] ?? ''), + 'slug' => $newSlug, + 'manifest' => $manifest, + ]; + } + + /** + * Rename a template's display name (the slug is stable once created). + */ + public function rename(string $scope, string $slug, string $newName, ?ReportTemplateType $type = null): void + { + $template = $this->load($scope, $slug, $type); + + if ($template === null) { + throw new RuntimeException("Report template [{$scope}/{$slug}] does not exist."); + } + + $manifest = $template['manifest']; + $manifest['name'] = $newName; + + Storage::disk(self::DISK)->put( + $this->path($scope, $slug, $type) . '/manifest.json', + $this->encodeJson($manifest), + ); + } + + /** + * Delete a template folder. Shipped system defaults are protected. + */ + public function delete(string $scope, string $slug, ?ReportTemplateType $type = null): bool + { + if ($scope === self::SCOPE_SYSTEM && $slug === 'default') { + throw new RuntimeException('System default templates cannot be deleted.'); + } + + $base = $this->path($scope, $slug, $type); + + if ( ! Storage::disk(self::DISK)->exists($base . '/manifest.json')) { + return false; + } + + return Storage::disk(self::DISK)->deleteDirectory($base); + } + + /** + * Reduce arbitrary decoded band data to the valid five-band structure. + * + * @return array> + */ + public function sanitizeBands(array $bands): array + { + $sanitized = []; + + foreach (ReportBand::ordered() as $band) { + $sanitized[$band->value] = []; + + $entries = $bands[$band->value] ?? []; + + if ( ! is_array($entries)) { + continue; + } + + foreach ($entries as $entry) { + if ( ! is_array($entry)) { + continue; + } + + $brickClass = ReportBricksCollection::findById((string) ($entry['brick'] ?? '')); + + if ($brickClass === null || ! in_array($band, $brickClass::allowedBands(), true)) { + continue; + } + + $width = ReportBlockWidth::tryFrom((string) ($entry['width'] ?? '')) ?? ReportBlockWidth::FULL; + $config = is_array($entry['config'] ?? null) ? $entry['config'] : []; + + $sanitized[$band->value][] = [ + 'brick' => $brickClass::getId(), + 'width' => $width->value, + 'config' => $brickClass::filterConfig($config), + ]; + } + } + + return $sanitized; + } + + /** + * Resolve the disk path for a template. Slugs are strictly validated + * ([a-z0-9-] only) so path traversal is impossible; company paths come + * from the tenant context exclusively. + */ + public function path(string $scope, string $slug, ?ReportTemplateType $type = null): string + { + $this->assertValidSlug($slug); + + if ($scope === self::SCOPE_SYSTEM) { + if ($type === null) { + throw new InvalidArgumentException('System template paths require a document type.'); + } + + return self::SCOPE_SYSTEM . '/' . $type->value . '/' . $slug; + } + + if ($scope === self::SCOPE_COMPANY) { + return $this->companyId() . '/' . $slug; + } + + throw new InvalidArgumentException("Unknown report template scope [{$scope}]."); + } + + protected function assertValidSlug(string $slug): void + { + if ($slug === '' || preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug) !== 1) { + throw new InvalidArgumentException("Invalid report template slug [{$slug}]."); + } + } + + /** + * Current company id from the tenant context (Filament tenant, then + * session, then the user's first company). Never caller-supplied. + */ + protected function companyId(): int + { + $tenant = Filament::getTenant(); + + if ($tenant !== null) { + return (int) $tenant->getKey(); + } + + if (session()?->has('current_company_id')) { + return (int) session('current_company_id'); + } + + $company = Auth::user()?->companies()->first(); + + if ($company !== null) { + return (int) $company->id; + } + + throw new RuntimeException('No company context available for report template storage.'); + } + + protected function uniqueSlug(string $scope, string $slug, ?ReportTemplateType $type): string + { + $this->assertValidSlug($slug); + + $candidate = $slug; + $suffix = 2; + + while ($this->exists($scope, $candidate, $type)) { + $candidate = $slug . '-' . $suffix++; + } + + return $candidate; + } + + protected function readJson(string $path): ?array + { + if ( ! Storage::disk(self::DISK)->exists($path)) { + return null; + } + + try { + $decoded = json_decode((string) Storage::disk(self::DISK)->get($path), true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + protected function encodeJson(array $data): string + { + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/Modules/Core/Support/PDF/Drivers/Browsershot.php b/Modules/Core/Support/PDF/Drivers/Browsershot.php new file mode 100644 index 000000000..562bc896d --- /dev/null +++ b/Modules/Core/Support/PDF/Drivers/Browsershot.php @@ -0,0 +1,70 @@ +getOutput($html)); + } + + public function getOutput($html) + { + return $this->getEngine($html)->pdf(); + } + + public function download($html, $filename) + { + $response = response($this->getOutput($html)); + + $response->header('Content-Type', 'application/pdf'); + $response->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); + + return $response->send(); + } + + protected function getEngine($html): BrowsershotEngine + { + $engine = BrowsershotEngine::html($html) + ->format($this->paperSize) + ->showBackground() + /* + * Report HTML is app-generated only (never user-authored) and + * references images by local path, which Chromium blocks from + * file:// pages unless file access is allowed. + */ + ->addChromiumArguments(['allow-file-access-from-files']); + + if ($this->paperOrientation === 'landscape') { + $engine->landscape(); + } + + if (config('ip.browsershot.node_binary')) { + $engine->setNodeBinary(config('ip.browsershot.node_binary')); + } + + if (config('ip.browsershot.npm_binary')) { + $engine->setNpmBinary(config('ip.browsershot.npm_binary')); + } + + if (config('ip.browsershot.chrome_path')) { + $engine->setChromePath(config('ip.browsershot.chrome_path')); + } + + if (config('ip.browsershot.no_sandbox')) { + $engine->noSandbox(); + } + + return $engine; + } +} diff --git a/Modules/Core/Support/PDF/Drivers/domPDF.php b/Modules/Core/Support/PDF/Drivers/domPDF.php index 1ab66b9c8..2904e22ea 100644 --- a/Modules/Core/Support/PDF/Drivers/domPDF.php +++ b/Modules/Core/Support/PDF/Drivers/domPDF.php @@ -2,6 +2,9 @@ namespace Modules\Core\Support\PDF\Drivers; +use Dompdf\Dompdf as DompdfEngine; +use Dompdf\Options; +use Illuminate\Support\Facades\File; use Modules\Core\Support\PDF\PDFAbstract; class domPDF extends PDFAbstract @@ -30,17 +33,21 @@ public function download($html, $filename) private function getPdf($html) { + $workDir = storage_path('app/dompdf'); + File::ensureDirectoryExists($workDir); + $options = new Options(); - $options->setTempDir(storage_path('/')); - $options->setFontDir(storage_path('/')); - $options->setFontCache(storage_path('/')); - $options->setLogOutputFile(storage_path('dompdf_log')); - $options->setIsRemoteEnabled(true); + $options->setTempDir($workDir); + $options->setFontDir($workDir); + $options->setFontCache($workDir); + $options->setLogOutputFile($workDir . '/dompdf.log'); + // Remote fetching stays disabled: images must resolve to local paths. + $options->setIsRemoteEnabled(false); $options->setIsHtml5ParserEnabled(true); $options->setIsFontSubsettingEnabled(true); - $pdf = new PDF($options); + $pdf = new DompdfEngine($options); $pdf->setPaper($this->paperSize, $this->paperOrientation); $pdf->loadHtml($html); diff --git a/Modules/Core/Tests/AbstractAdminPanelTestCase.php b/Modules/Core/Tests/AbstractAdminPanelTestCase.php index 5ea2f7670..a484e8a79 100644 --- a/Modules/Core/Tests/AbstractAdminPanelTestCase.php +++ b/Modules/Core/Tests/AbstractAdminPanelTestCase.php @@ -5,6 +5,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -34,6 +37,14 @@ protected function setUp(): void session(['current_company_id' => $this->company->id]); + /* + * Admin resources gate every page on Spatie permissions (canViewAny + * etc.), so the test user needs the seeded super_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->superAdmin->assignRole(UserRole::SUPER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php index 1be904606..e4b9df17b 100644 --- a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php +++ b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php @@ -3,10 +3,13 @@ namespace Modules\Core\Tests; use Filament\Facades\Filament; -use Filament\Schemas\Components\Livewire; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Livewire\Livewire; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -44,6 +47,14 @@ protected function setUp(): void $currentCompanyId = $this->user->getCurrentCompanyId(); session(['current_company_id' => $currentCompanyId]); + /* + * Resources gate every page on Spatie permissions (canViewAny etc.), + * so the test user needs the seeded client_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $this->withoutExceptionHandling(); } @@ -58,8 +69,9 @@ protected function tearDown(): void */ protected function testLivewire($component, $params = []) { - return Livewire::actingAs($this->user) - ->withSession(['current_company_id' => $this->company->id]) - ->test($component, $params); + $this->actingAs($this->user) + ->withSession(['current_company_id' => $this->company->id]); + + return Livewire::test($component, $params); } } diff --git a/Modules/Core/Tests/Feature/AdminReportBuilderTest.php b/Modules/Core/Tests/Feature/AdminReportBuilderTest.php new file mode 100644 index 000000000..e60d7ddfb --- /dev/null +++ b/Modules/Core/Tests/Feature/AdminReportBuilderTest.php @@ -0,0 +1,145 @@ +storage = new ReportTemplateStorage(); + + $this->artisan('reports:sync-system'); + } + + #[Test] + public function it_lists_system_templates_on_the_admin_list_page(): void + { + /* Act & Assert */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportTemplates::class) + ->assertSuccessful() + ->assertSee('Default Invoice') + ->assertSee('Default Quote'); + } + + #[Test] + public function it_opens_the_builder_for_a_system_template_with_five_bands(): void + { + /* Act */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']) + ->assertSuccessful(); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame( + ['header', 'group_header', 'details', 'group_footer', 'footer'], + array_keys($bands), + ); + $this->assertSame('header_company', $bands['header'][0]['attrs']['id']); + $this->assertSame('detail_items', $bands['details'][0]['attrs']['id']); + } + + #[Test] + public function it_returns_not_found_for_an_unknown_template(): void + { + /* Assert */ + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + /* Act */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'nope']); + } + + #[Test] + public function it_saves_the_edited_bands_back_to_disk(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + $bands = $component->get('data.bands'); + $bands['header'][] = [ + 'type' => 'masonBrick', + 'attrs' => ['id' => 'spacer', 'config' => ['height' => 33]], + ]; + + /* Act */ + $component->set('data.bands', $bands)->call('save')->assertHasNoErrors(); + + /* Assert */ + $saved = $this->storage->load('system', 'default', ReportTemplateType::INVOICE); + $lastHeader = end($saved['bands']['header']); + $this->assertSame('spacer', $lastHeader['brick']); + $this->assertSame(['height' => 33], $lastHeader['config']); + } + + #[Test] + public function it_moves_a_brick_to_an_allowed_band(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Act — header_company may live in header or group_header */ + $component->call('moveBrick', 'header', 0, 'group_header'); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame('header_client', $bands['header'][0]['attrs']['id']); + $this->assertSame('header_company', end($bands['group_header'])['attrs']['id']); + } + + #[Test] + public function it_refuses_to_move_a_brick_into_a_disallowed_band(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Act — detail_items must never land in the header band */ + $component->call('moveBrick', 'details', 0, 'header'); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame('detail_items', $bands['details'][0]['attrs']['id']); + + foreach ($bands['header'] as $node) { + $this->assertNotSame('detail_items', $node['attrs']['id']); + } + } + + #[Test] + public function it_clones_a_system_template_into_the_system_scope_from_the_admin_panel(): void + { + /* Act */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportTemplates::class) + ->callAction('clone', data: ['name' => 'Modern Invoice'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]) + ->assertHasNoErrors(); + + /* Assert */ + $clone = $this->storage->load('system', 'modern-invoice', ReportTemplateType::INVOICE); + $this->assertNotNull($clone); + $this->assertSame('Modern Invoice', $clone['manifest']['name']); + } +} diff --git a/Modules/Core/Tests/Feature/CompanyReportBuilderTest.php b/Modules/Core/Tests/Feature/CompanyReportBuilderTest.php new file mode 100644 index 000000000..68844d3db --- /dev/null +++ b/Modules/Core/Tests/Feature/CompanyReportBuilderTest.php @@ -0,0 +1,144 @@ +storage = new ReportTemplateStorage(); + + $this->artisan('reports:sync-system'); + } + + #[Test] + public function it_lists_system_and_company_templates_on_the_company_list_page(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Our Invoice', ReportTemplateType::INVOICE); + + /* Act & Assert */ + $this->testLivewire(ReportTemplates::class) + ->assertSuccessful() + ->assertSee('Default Invoice') + ->assertSee('Our Invoice'); + } + + #[Test] + public function it_treats_system_templates_as_read_only_in_the_company_panel(): void + { + /* Act */ + $component = $this->testLivewire(ReportBuilder::class, [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ])->assertSuccessful(); + + /* Assert */ + $this->assertFalse($component->instance()->canSave()); + } + + #[Test] + public function it_blocks_saving_a_system_template_from_the_company_panel(): void + { + /* Arrange */ + $component = $this->testLivewire(ReportBuilder::class, [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]); + + /* Assert */ + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + + /* Act */ + $component->call('save'); + } + + #[Test] + public function it_clones_a_system_template_and_saves_the_editable_company_copy(): void + { + /* Arrange */ + $this->testLivewire(ReportTemplates::class) + ->callAction('clone', data: ['name' => 'Our Invoice'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]) + ->assertHasNoErrors(); + + /* Act */ + $component = $this->testLivewire(ReportBuilder::class, [ + 'scope' => 'company', + 'type' => 'invoice', + 'slug' => 'our-invoice', + ])->assertSuccessful(); + + $this->assertTrue($component->instance()->canSave()); + + $bands = $component->get('data.bands'); + $bands['footer'][] = [ + 'type' => 'masonBrick', + 'attrs' => ['id' => 'page_break', 'config' => []], + ]; + + $component->set('data.bands', $bands)->call('save')->assertHasNoErrors(); + + /* Assert */ + $saved = $this->storage->load('company', 'our-invoice'); + $lastFooter = end($saved['bands']['footer']); + $this->assertSame('page_break', $lastFooter['brick']); + } + + #[Test] + public function it_deletes_a_company_template_from_the_list_page(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Doomed', ReportTemplateType::INVOICE); + + /* Act */ + $this->testLivewire(ReportTemplates::class) + ->callAction('delete', arguments: [ + 'scope' => 'company', + 'type' => 'invoice', + 'slug' => 'doomed', + ]) + ->assertHasNoErrors(); + + /* Assert */ + $this->assertNull($this->storage->load('company', 'doomed')); + } + + #[Test] + public function it_renames_a_company_template_from_the_list_page(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Old Name', ReportTemplateType::INVOICE); + + /* Act */ + $this->testLivewire(ReportTemplates::class) + ->callAction('rename', data: ['name' => 'New Name'], arguments: [ + 'scope' => 'company', + 'type' => 'invoice', + 'slug' => 'old-name', + ]) + ->assertHasNoErrors(); + + /* Assert */ + $this->assertSame('New Name', $this->storage->load('company', 'old-name')['manifest']['name']); + } +} diff --git a/Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php b/Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php new file mode 100644 index 000000000..53d98e1a2 --- /dev/null +++ b/Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php @@ -0,0 +1,120 @@ +storage = new ReportTemplateStorage(); + + $this->artisan('reports:sync-system'); + } + + #[Test] + public function it_lists_disk_templates_as_options_for_the_invoice_type(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Fancy', ReportTemplateType::INVOICE); + + /* Act */ + $options = $this->storage->optionsForType(ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertSame(['default' => 'Default Invoice', 'fancy' => 'Fancy'], $options); + } + + #[Test] + public function it_does_not_offer_quote_templates_for_invoices(): void + { + /* Act */ + $options = $this->storage->optionsForType(ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertArrayHasKey('default', $options); + $this->assertNotContains('Default Quote', $options); + } + + #[Test] + public function it_shadows_a_system_template_with_a_company_clone_of_the_same_slug(): void + { + /* Arrange — clone, then rename the clone's manifest name */ + $clone = $this->storage->clone('system', 'default', 'Default', ReportTemplateType::INVOICE); + $this->storage->rename('company', $clone['slug'], 'Our House Default'); + + /* Act */ + $options = $this->storage->optionsForType(ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertSame('Our House Default', $options['default']); + } + + #[Test] + public function it_persists_the_selected_template_slug_on_the_invoice(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Fancy', ReportTemplateType::INVOICE); + $invoice = $this->invoice(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->fillForm(['template' => 'fancy']) + ->call('save') + ->assertHasNoErrors(); + + /* Assert */ + $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'template' => 'fancy']); + } + + #[Test] + public function it_allows_clearing_the_template_back_to_the_company_default(): void + { + /* Arrange */ + $invoice = $this->invoice(); + $invoice->update(['template' => 'default']); + + /* Act */ + Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->fillForm(['template' => null]) + ->call('save') + ->assertHasNoErrors(); + + /* Assert */ + $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'template' => null]); + } + + protected function invoice(): Invoice + { + $relation = Relation::factory()->create(['company_id' => $this->company->id]); + $numbering = \Modules\Core\Models\Numbering::factory()->create([ + 'company_id' => $this->company->id, + 'type' => \Modules\Core\Enums\NumberingType::INVOICE->value, + ]); + + return Invoice::factory()->create([ + 'company_id' => $this->company->id, + 'customer_id' => $relation->id, + 'user_id' => $this->user->id, + 'numbering_id' => $numbering->id, + 'template' => null, + ]); + } +} diff --git a/Modules/Core/Tests/Feature/PdfGenerationServiceTest.php b/Modules/Core/Tests/Feature/PdfGenerationServiceTest.php new file mode 100644 index 000000000..8ab20eb05 --- /dev/null +++ b/Modules/Core/Tests/Feature/PdfGenerationServiceTest.php @@ -0,0 +1,266 @@ +artisan('reports:sync-system'); + + $this->service = app(PdfGenerationService::class); + } + + #[Test] + public function it_resolves_the_system_default_template_when_nothing_is_configured(): void + { + /* Act */ + $template = $this->service->resolveTemplate($this->goldenInvoice()); + + /* Assert */ + $this->assertSame('default', $template['manifest']['slug']); + $this->assertSame('invoice', $template['manifest']['type']); + } + + #[Test] + public function it_prefers_the_documents_own_template_slug(): void + { + /* Arrange */ + $storage = new ReportTemplateStorage(); + $storage->clone('system', 'default', 'Special', \Modules\Core\Enums\ReportTemplateType::INVOICE); + + $invoice = $this->goldenInvoice(); + $invoice->update(['template' => 'special']); + + /* Act */ + $template = $this->service->resolveTemplate($invoice->fresh()); + + /* Assert */ + $this->assertSame('special', $template['manifest']['slug']); + } + + #[Test] + public function it_falls_back_to_the_company_default_template(): void + { + /* Arrange */ + $storage = new ReportTemplateStorage(); + $storage->clone('system', 'default', 'House Style', \Modules\Core\Enums\ReportTemplateType::INVOICE); + + $this->company->update(['invoice_template' => 'house-style']); + + /* Act */ + $template = $this->service->resolveTemplate($this->goldenInvoice()); + + /* Assert */ + $this->assertSame('house-style', $template['manifest']['slug']); + } + + #[Test] + public function it_falls_back_to_default_for_an_unknown_template_slug(): void + { + /* Arrange */ + $invoice = $this->goldenInvoice(); + $invoice->update(['template' => 'never-existed']); + + /* Act */ + $template = $this->service->resolveTemplate($invoice->fresh()); + + /* Assert */ + $this->assertSame('default', $template['manifest']['slug']); + } + + #[Test] + public function it_renders_invoice_html_containing_the_invoice_data(): void + { + /* Act */ + $html = $this->service->renderInvoiceHtml($this->goldenInvoice()); + + /* Assert */ + $this->assertStringContainsString('INV-GOLD-0001', $html); + $this->assertStringContainsString('Golden Client Ltd', $html); + $this->assertStringContainsString('Golden Widget', $html); + $this->assertStringContainsString('121.00', $html); + $this->assertStringContainsString('Golden footer', $html); + } + + #[Test] + public function it_produces_non_empty_pdf_bytes_for_an_invoice(): void + { + /* Act */ + $pdf = $this->service->invoicePdf($this->goldenInvoice()); + + /* Assert */ + $this->assertNotEmpty($pdf); + $this->assertStringStartsWith('%PDF', $pdf); + } + + #[Test] + public function it_produces_non_empty_pdf_bytes_for_a_quote(): void + { + /* Act */ + $pdf = $this->service->quotePdf($this->goldenQuote()); + + /* Assert */ + $this->assertNotEmpty($pdf); + $this->assertStringStartsWith('%PDF', $pdf); + } + + #[Test] + public function it_matches_the_golden_html_snapshot_for_the_default_quote_template(): void + { + /* Arrange */ + $fixture = __DIR__ . '/../Fixtures/report-templates/quote-default.html'; + + /* Act */ + $html = $this->service->renderQuoteHtml($this->goldenQuote()); + + $this->assertStringContainsString('Q-GOLD-0001', $html); + $this->assertStringContainsString('Golden Quote Widget', $html); + + if ( ! is_file($fixture)) { + @mkdir(dirname($fixture), 0775, true); + file_put_contents($fixture, $html); + $this->markTestIncomplete('Golden fixture created — rerun to verify.'); + } + + /* Assert */ + $this->assertSame(file_get_contents($fixture), $html); + } + + #[Test] + public function it_matches_the_golden_html_snapshot_for_the_default_invoice_template(): void + { + /* Arrange */ + $fixture = __DIR__ . '/../Fixtures/report-templates/invoice-default.html'; + + /* Act */ + $html = $this->service->renderInvoiceHtml($this->goldenInvoice()); + + if ( ! is_file($fixture)) { + @mkdir(dirname($fixture), 0775, true); + file_put_contents($fixture, $html); + $this->markTestIncomplete('Golden fixture created — rerun to verify.'); + } + + /* Assert */ + $this->assertSame(file_get_contents($fixture), $html); + } + + protected function goldenInvoice(): Invoice + { + $this->company->update(['vat_number' => 'VAT-GOLD-1', 'logo' => null]); + + $relation = Relation::factory()->create([ + 'company_id' => $this->company->id, + 'company_name' => 'Golden Client Ltd', + ]); + + $relation->addresses()->delete(); + $relation->addresses()->create([ + 'company_id' => $this->company->id, + 'address_type' => 'billing', + 'address_1' => 'Golden Street 1', + 'postal_code' => '1234 AB', + 'city' => 'Goldenburg', + 'country' => 'NL', + ]); + + $invoice = Invoice::factory()->create([ + 'company_id' => $this->company->id, + 'customer_id' => $relation->id, + 'user_id' => $this->user->id, + 'invoice_number' => 'INV-GOLD-0001', + 'invoice_status' => 'sent', + 'invoice_sign' => '1', + 'invoiced_at' => '2026-01-01', + 'invoice_due_at' => '2026-01-31', + 'invoice_discount_amount' => 0, + 'invoice_discount_percent' => 0, + 'invoice_item_subtotal' => 100.0000, + 'item_tax_total' => 21.0000, + 'invoice_tax_total' => 21.0000, + 'invoice_total' => 121.0000, + 'template' => null, + 'summary' => 'Golden summary', + 'terms' => 'Golden terms', + 'footer' => 'Golden footer', + ]); + + InvoiceItem::factory()->create([ + 'company_id' => $this->company->id, + 'invoice_id' => $invoice->id, + 'item_name' => 'Golden Widget', + 'quantity' => 2, + 'price' => 50.0000, + 'subtotal' => 100.0000, + 'tax_1' => 21.0000, + 'tax_2' => 0, + 'tax_total' => 21.0000, + 'total' => 121.0000, + ]); + + return $invoice->fresh(); + } + + protected function goldenQuote(): \Modules\Quotes\Models\Quote + { + $this->company->update(['vat_number' => 'VAT-GOLD-1', 'logo' => null]); + + $relation = Relation::factory()->create([ + 'company_id' => $this->company->id, + 'company_name' => 'Golden Client Ltd', + ]); + $relation->addresses()->delete(); + + $quote = \Modules\Quotes\Models\Quote::factory()->create([ + 'company_id' => $this->company->id, + 'prospect_id' => $relation->id, + 'user_id' => $this->user->id, + 'quote_number' => 'Q-GOLD-0001', + 'quote_status' => 'sent', + 'quoted_at' => '2026-01-01', + 'quote_expires_at' => '2026-01-31', + 'quote_discount_amount' => 0, + 'quote_discount_percent' => 0, + 'quote_item_subtotal' => 100.0000, + 'item_tax_total' => 21.0000, + 'quote_tax_total' => 21.0000, + 'quote_total' => 121.0000, + 'template' => null, + 'summary' => 'Golden quote summary', + 'terms' => 'Golden quote terms', + 'footer' => 'Golden quote footer', + ]); + + $quote->quoteItems()->delete(); + \Modules\Quotes\Models\QuoteItem::factory()->create([ + 'company_id' => $this->company->id, + 'quote_id' => $quote->id, + 'item_name' => 'Golden Quote Widget', + 'quantity' => 2, + 'price' => 50.0000, + 'subtotal' => 100.0000, + 'tax_1' => 21.0000, + 'tax_2' => 0, + 'tax_total' => 21.0000, + 'total' => 121.0000, + ]); + + return $quote->fresh(); + } +} diff --git a/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php b/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php new file mode 100644 index 000000000..172a5252b --- /dev/null +++ b/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php @@ -0,0 +1,71 @@ +artisan('reports:sync-system')->assertSuccessful(); + + /* Assert */ + $disk = Storage::disk(ReportTemplateStorage::DISK); + $this->assertTrue($disk->exists('system/invoice/default/manifest.json')); + $this->assertTrue($disk->exists('system/invoice/default/bands.json')); + $this->assertTrue($disk->exists('system/quote/default/manifest.json')); + $this->assertTrue($disk->exists('system/quote/default/bands.json')); + } + + #[Test] + public function it_is_idempotent_when_run_twice(): void + { + /* Act */ + $this->artisan('reports:sync-system')->assertSuccessful(); + $firstRun = Storage::disk(ReportTemplateStorage::DISK)->allFiles('system'); + + $this->artisan('reports:sync-system')->assertSuccessful(); + $secondRun = Storage::disk(ReportTemplateStorage::DISK)->allFiles('system'); + + /* Assert */ + $this->assertSame($firstRun, $secondRun); + + $storage = new ReportTemplateStorage(); + $this->assertCount(2, $storage->listSystem()); + } + + #[Test] + public function it_loads_a_synced_default_template_with_valid_bands(): void + { + /* Arrange */ + $this->artisan('reports:sync-system')->assertSuccessful(); + + /* Act */ + $storage = new ReportTemplateStorage(); + $template = $storage->load( + ReportTemplateStorage::SCOPE_SYSTEM, + 'default', + \Modules\Core\Enums\ReportTemplateType::INVOICE, + ); + + /* Assert */ + $this->assertNotNull($template); + $this->assertSame('invoice', $template['manifest']['type']); + $this->assertNotEmpty($template['bands']['header']); + $this->assertNotEmpty($template['bands']['details']); + $this->assertNotEmpty($template['bands']['footer']); + } +} diff --git a/Modules/Core/Tests/Fixtures/report-templates/invoice-default.html b/Modules/Core/Tests/Fixtures/report-templates/invoice-default.html new file mode 100644 index 000000000..05fd8d2f3 --- /dev/null +++ b/Modules/Core/Tests/Fixtures/report-templates/invoice-default.html @@ -0,0 +1,96 @@ + + + + + Default Invoice + + + +
+ + + + +
+ InvoicePlane Corporation
+
+
+ Phone:
+ Email:
+ Vat id: VAT-GOLD-1
+
+
+
+ Bill To
+ Golden Client Ltd
+ Golden Street 1
+ Goldenburg 1234 AB
+ Phone:
+ Email:
+
+
+
+ + + + + + + + + + + + + + + + + + + +
Task descriptionQuantityPriceTaxTotal
Golden Widget250.0021.00121.00
+
+
+ + \ No newline at end of file diff --git a/Modules/Core/Tests/Fixtures/report-templates/quote-default.html b/Modules/Core/Tests/Fixtures/report-templates/quote-default.html new file mode 100644 index 000000000..79f377007 --- /dev/null +++ b/Modules/Core/Tests/Fixtures/report-templates/quote-default.html @@ -0,0 +1,84 @@ + + + + + Default Quote + + + +
+ + + + +
+ InvoicePlane Corporation
+
+
+ Phone:
+ Email:
+ Vat id: VAT-GOLD-1
+
+
+
+ Bill To
+ Golden Client Ltd
+
+
+ Phone:
+ Email:
+
+
+
Quote Number: Q-GOLD-0001
+
Quote Date: 2026-01-01
+
Expiry Date: 2026-01-31
+
Status: sent
+
+
+ + + + + + + + + + + + + + + + + + + +
Task descriptionQuantityPriceTaxTotal
Golden Quote Widget250.0021.00121.00
+
+
+ + \ No newline at end of file diff --git a/Modules/Core/Tests/Unit/BrowsershotDriverTest.php b/Modules/Core/Tests/Unit/BrowsershotDriverTest.php new file mode 100644 index 000000000..875f80296 --- /dev/null +++ b/Modules/Core/Tests/Unit/BrowsershotDriverTest.php @@ -0,0 +1,41 @@ +set('ip.pdfDriver', 'Browsershot'); + + /* Assert */ + $this->assertInstanceOf(Browsershot::class, PDFFactory::create()); + } + + #[Test] + public function it_produces_pdf_bytes_from_html_when_chromium_is_available(): void + { + /* Arrange — opt-in driver: skip on hosts without Node/Chromium */ + if (mb_trim((string) shell_exec('command -v node 2>/dev/null')) === '') { + $this->markTestSkipped('Node is not available on this host.'); + } + + try { + $output = (new Browsershot())->getOutput('

Hello Chromium PDF

'); + } catch (Throwable $e) { + $this->markTestSkipped('Chromium/Puppeteer is not available: ' . mb_substr($e->getMessage(), 0, 120)); + } + + /* Assert */ + $this->assertNotEmpty($output); + $this->assertStringStartsWith('%PDF', $output); + } +} diff --git a/Modules/Core/Tests/Unit/DomPdfDriverTest.php b/Modules/Core/Tests/Unit/DomPdfDriverTest.php new file mode 100644 index 000000000..6698a7205 --- /dev/null +++ b/Modules/Core/Tests/Unit/DomPdfDriverTest.php @@ -0,0 +1,29 @@ +getOutput('

Hello PDF

'); + + /* Assert */ + $this->assertNotEmpty($output); + $this->assertStringStartsWith('%PDF', $output); + } + + #[Test] + public function it_is_the_configured_default_driver(): void + { + /* Assert */ + $this->assertInstanceOf(domPDF::class, PDFFactory::create()); + } +} diff --git a/Modules/Core/Tests/Unit/MasonBricksTest.php b/Modules/Core/Tests/Unit/MasonBricksTest.php new file mode 100644 index 000000000..916940c8d --- /dev/null +++ b/Modules/Core/Tests/Unit/MasonBricksTest.php @@ -0,0 +1,288 @@ +assertEquals('header_company', $id); + } + + #[Test] + public function it_header_company_brick_generates_preview_html(): void + { + /* Arrange */ + $config = [ + 'show_vat_id' => true, + 'show_phone' => true, + 'font_size' => 10, + ]; + + /* Act */ + $html = HeaderCompanyBrick::toPreviewHtml($config); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString(trans('ip.company_name'), $html); + } + + #[Test] + public function it_header_company_brick_generates_render_html(): void + { + /* Arrange */ + $config = ['show_vat_id' => true]; + $data = [ + 'company' => [ + 'name' => 'Test Company', + 'vat_id' => '123456', + ], + ]; + + /* Act */ + $html = HeaderCompanyBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Company', $html); + } + + #[Test] + public function it_header_client_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderClientBrick::getId(); + + /* Assert */ + $this->assertEquals('header_client', $id); + } + + #[Test] + public function it_header_client_brick_generates_html(): void + { + /* Arrange */ + $config = ['show_phone' => true]; + $data = [ + 'client' => [ + 'name' => 'Test Client', + 'phone' => '555-1234', + ], + ]; + + /* Act */ + $html = HeaderClientBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Client', $html); + } + + #[Test] + public function it_header_invoice_meta_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderInvoiceMetaBrick::getId(); + + /* Assert */ + $this->assertEquals('header_invoice_meta', $id); + } + + #[Test] + public function it_header_invoice_meta_brick_shows_configured_fields(): void + { + /* Arrange */ + $config = [ + 'show_invoice_number' => true, + 'show_invoice_date' => true, + 'show_due_date' => false, + ]; + $data = [ + 'invoice' => [ + 'number' => 'INV-001', + 'date' => '2024-01-01', + ], + ]; + + /* Act */ + $html = HeaderInvoiceMetaBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('INV-001', $html); + } + + #[Test] + public function it_detail_items_brick_has_correct_id(): void + { + /* Act */ + $id = DetailItemsBrick::getId(); + + /* Assert */ + $this->assertEquals('detail_items', $id); + } + + #[Test] + public function it_detail_items_brick_renders_items_table(): void + { + /* Arrange */ + $config = [ + 'show_description' => true, + 'show_quantity' => true, + 'show_price' => true, + ]; + $data = [ + 'items' => [ + [ + 'description' => 'Item 1', + 'quantity' => 2, + 'price' => '100.00', + ], + ], + ]; + + /* Act */ + $html = DetailItemsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Item 1', $html); + } + + #[Test] + public function it_footer_totals_brick_has_correct_id(): void + { + /* Act */ + $id = FooterTotalsBrick::getId(); + + /* Assert */ + $this->assertEquals('footer_totals', $id); + } + + #[Test] + public function it_footer_totals_brick_displays_configured_totals(): void + { + /* Arrange */ + $config = [ + 'show_subtotal' => true, + 'show_tax' => true, + 'show_total' => true, + ]; + $data = [ + 'totals' => [ + 'subtotal' => '100.00', + 'tax' => '10.00', + 'total' => '110.00', + ], + ]; + + /* Act */ + $html = FooterTotalsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('110.00', $html); + } + + #[Test] + public function it_footer_notes_brick_has_correct_id(): void + { + /* Act */ + $id = FooterNotesBrick::getId(); + + /* Assert */ + $this->assertEquals('footer_notes', $id); + } + + #[Test] + public function it_footer_notes_brick_renders_custom_content(): void + { + /* Arrange */ + $config = [ + 'footer_content' => '

Custom payment terms

', + ]; + $data = []; + + /* Act */ + $html = FooterNotesBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Custom payment terms', $html); + } + + #[Test] + public function it_all_bricks_have_unique_ids(): void + { + /* Arrange */ + $bricks = [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + DetailItemsBrick::class, + FooterTotalsBrick::class, + FooterNotesBrick::class, + ]; + + /* Act */ + $ids = array_map(fn ($brick) => $brick::getId(), $bricks); + + /* Assert */ + $this->assertCount(6, array_unique($ids)); + $this->assertCount(6, $ids); + } + + #[Test] + public function it_all_bricks_return_labels(): void + { + /* Arrange */ + $bricks = [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + DetailItemsBrick::class, + FooterTotalsBrick::class, + FooterNotesBrick::class, + ]; + + /* Act & Assert */ + foreach ($bricks as $brick) { + $label = $brick::getLabel(); + $this->assertIsString($label); + $this->assertNotEmpty($label); + } + } + + #[Test] + public function it_all_bricks_return_icons(): void + { + /* Arrange */ + $bricks = [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + DetailItemsBrick::class, + FooterTotalsBrick::class, + FooterNotesBrick::class, + ]; + + /* Act & Assert */ + foreach ($bricks as $brick) { + $icon = $brick::getIcon(); + $this->assertNotNull($icon); + } + } +} diff --git a/Modules/Core/Tests/Unit/MasonDocumentConverterTest.php b/Modules/Core/Tests/Unit/MasonDocumentConverterTest.php new file mode 100644 index 000000000..8080e86f8 --- /dev/null +++ b/Modules/Core/Tests/Unit/MasonDocumentConverterTest.php @@ -0,0 +1,105 @@ + 'header_company', 'width' => 'half', 'config' => ['show_vat_id' => true]], + ]); + + /* Assert */ + $this->assertCount(1, $state); + $this->assertSame('masonBrick', $state[0]['type']); + $this->assertSame('header_company', $state[0]['attrs']['id']); + $this->assertSame('half', $state[0]['attrs']['config'][MasonDocumentConverter::WIDTH_KEY]); + $this->assertTrue($state[0]['attrs']['config']['show_vat_id']); + $this->assertNotEmpty($state[0]['attrs']['label']); + $this->assertNotEmpty($state[0]['attrs']['preview']); + } + + #[Test] + public function it_skips_unknown_bricks_when_building_mason_state(): void + { + /* Act */ + $state = MasonDocumentConverter::toMasonState([ + ['brick' => 'does_not_exist', 'width' => 'full', 'config' => []], + ]); + + /* Assert */ + $this->assertSame([], $state); + } + + #[Test] + public function it_round_trips_entries_through_mason_state(): void + { + /* Arrange */ + $entries = [ + ['brick' => 'header_company', 'width' => 'half', 'config' => ['show_vat_id' => false]], + ['brick' => 'header_client', 'width' => 'full', 'config' => []], + ]; + + /* Act */ + $roundTripped = MasonDocumentConverter::toBandEntries( + MasonDocumentConverter::toMasonState($entries), + ); + + /* Assert */ + $this->assertSame('header_company', $roundTripped[0]['brick']); + $this->assertSame('half', $roundTripped[0]['width']); + $this->assertSame(['show_vat_id' => false], $roundTripped[0]['config']); + $this->assertSame('header_client', $roundTripped[1]['brick']); + $this->assertSame('full', $roundTripped[1]['width']); + $this->assertArrayNotHasKey(MasonDocumentConverter::WIDTH_KEY, $roundTripped[0]['config']); + } + + #[Test] + public function it_ignores_non_brick_nodes_in_mason_state(): void + { + /* Act */ + $entries = MasonDocumentConverter::toBandEntries([ + ['type' => 'paragraph', 'content' => []], + ['type' => 'masonBrick', 'attrs' => ['id' => 'spacer', 'config' => ['height' => 30]]], + ]); + + /* Assert */ + $this->assertCount(1, $entries); + $this->assertSame('spacer', $entries[0]['brick']); + } + + #[Test] + public function it_unwraps_a_content_wrapped_mason_document(): void + { + /* Act */ + $entries = MasonDocumentConverter::toBandEntries([ + 'type' => 'doc', + 'content' => [ + ['type' => 'masonBrick', 'attrs' => ['id' => 'page_break', 'config' => []]], + ], + ]); + + /* Assert */ + $this->assertCount(1, $entries); + $this->assertSame('page_break', $entries[0]['brick']); + } + + #[Test] + public function it_defaults_invalid_widths_to_full(): void + { + /* Act */ + $entries = MasonDocumentConverter::toBandEntries([ + ['type' => 'masonBrick', 'attrs' => ['id' => 'spacer', 'config' => [MasonDocumentConverter::WIDTH_KEY => 'bogus']]], + ]); + + /* Assert */ + $this->assertSame('full', $entries[0]['width']); + } +} diff --git a/Modules/Core/Tests/Unit/ReportBrickTest.php b/Modules/Core/Tests/Unit/ReportBrickTest.php new file mode 100644 index 000000000..16f24baf8 --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportBrickTest.php @@ -0,0 +1,96 @@ +assertSame([ReportBand::HEADER, ReportBand::GROUP_HEADER], HeaderCompanyBrick::allowedBands()); + $this->assertSame([ReportBand::DETAILS], DetailItemsBrick::allowedBands()); + $this->assertSame([ReportBand::GROUP_FOOTER, ReportBand::FOOTER], FooterTotalsBrick::allowedBands()); + } + + #[Test] + public function it_derives_config_keys_from_the_configure_action_schema(): void + { + /* Act */ + $keys = HeaderCompanyBrick::configKeys(); + + /* Assert */ + $this->assertContains('show_vat_id', $keys); + $this->assertContains('font_size', $keys); + $this->assertNotContains('unknown_key', $keys); + } + + #[Test] + public function it_reports_config_keys_for_every_registered_brick_without_error(): void + { + /* Assert */ + foreach (ReportBricksCollection::all() as $brick) { + $this->assertIsArray($brick::configKeys(), "configKeys failed for {$brick}"); + } + } + + #[Test] + public function it_filters_config_to_declared_keys_only(): void + { + /* Act */ + $filtered = HeaderCompanyBrick::filterConfig([ + 'show_vat_id' => true, + 'injected' => 'payload', + ]); + + /* Assert */ + $this->assertSame(['show_vat_id' => true], $filtered); + } + + #[Test] + public function it_has_no_config_keys_for_the_page_break_brick(): void + { + /* Assert */ + $this->assertSame([], PageBreakBrick::configKeys()); + } + + #[Test] + public function it_renders_every_registered_brick_preview_and_html_without_error(): void + { + /* Assert */ + foreach (ReportBricksCollection::all() as $brick) { + $this->assertIsString($brick::toPreviewHtml([]), "toPreviewHtml failed for {$brick}"); + $this->assertIsString($brick::toHtml([], []), "toHtml failed for {$brick}"); + } + } + + #[Test] + public function it_renders_a_page_break_as_page_break_css(): void + { + /* Act */ + $html = PageBreakBrick::toHtml([], []); + + /* Assert */ + $this->assertStringContainsString('page-break-after: always', $html); + } + + #[Test] + public function it_renders_the_spacer_with_the_configured_height(): void + { + /* Act */ + $html = SpacerBrick::toHtml(['height' => 42], []); + + /* Assert */ + $this->assertStringContainsString('height: 42px', $html); + } +} diff --git a/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php b/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php new file mode 100644 index 000000000..5eb27fb05 --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportBricksCollectionTest.php @@ -0,0 +1,167 @@ +assertIsArray($bricks); + $this->assertCount(19, $bricks); + } + + #[Test] + public function it_returns_header_bricks(): void + { + /* Act */ + $headerBricks = ReportBricksCollection::header(); + + /* Assert */ + $this->assertIsArray($headerBricks); + $this->assertCount(5, $headerBricks); + $this->assertContains(HeaderCompanyBrick::class, $headerBricks); + $this->assertContains(HeaderClientBrick::class, $headerBricks); + $this->assertContains(HeaderInvoiceMetaBrick::class, $headerBricks); + $this->assertContains(HeaderQuoteMetaBrick::class, $headerBricks); + $this->assertContains(HeaderProjectBrick::class, $headerBricks); + } + + #[Test] + public function it_returns_detail_bricks(): void + { + /* Act */ + $detailBricks = ReportBricksCollection::detail(); + + /* Assert */ + $this->assertIsArray($detailBricks); + $this->assertCount(8, $detailBricks); + $this->assertContains(DetailItemsBrick::class, $detailBricks); + $this->assertContains(DetailTasksBrick::class, $detailBricks); + $this->assertContains(DetailInvoiceProductBrick::class, $detailBricks); + $this->assertContains(DetailInvoiceProjectBrick::class, $detailBricks); + $this->assertContains(DetailQuoteProductBrick::class, $detailBricks); + $this->assertContains(DetailQuoteProjectBrick::class, $detailBricks); + $this->assertContains(DetailCustomerAgingBrick::class, $detailBricks); + $this->assertContains(DetailExpenseBrick::class, $detailBricks); + } + + #[Test] + public function it_returns_footer_bricks(): void + { + /* Act */ + $footerBricks = ReportBricksCollection::footer(); + + /* Assert */ + $this->assertIsArray($footerBricks); + $this->assertCount(4, $footerBricks); + $this->assertContains(FooterTotalsBrick::class, $footerBricks); + $this->assertContains(FooterNotesBrick::class, $footerBricks); + $this->assertContains(FooterTermsBrick::class, $footerBricks); + $this->assertContains(FooterSummaryBrick::class, $footerBricks); + } + + #[Test] + public function it_all_method_combines_all_sections(): void + { + /* Arrange */ + $headerCount = count(ReportBricksCollection::header()); + $detailCount = count(ReportBricksCollection::detail()); + $footerCount = count(ReportBricksCollection::footer()); + $utilityCount = count(ReportBricksCollection::utility()); + + /* Act */ + $allBricks = ReportBricksCollection::all(); + + /* Assert */ + $this->assertCount($headerCount + $detailCount + $footerCount + $utilityCount, $allBricks); + } + + #[Test] + public function it_returns_only_bricks_allowed_in_the_requested_band(): void + { + /* Act */ + $headerBricks = ReportBricksCollection::forBand(ReportBand::HEADER); + $detailBricks = ReportBricksCollection::forBand(ReportBand::DETAILS); + + /* Assert */ + $this->assertContains(HeaderCompanyBrick::class, $headerBricks); + $this->assertNotContains(DetailItemsBrick::class, $headerBricks); + $this->assertNotContains(FooterTotalsBrick::class, $headerBricks); + + $this->assertContains(DetailItemsBrick::class, $detailBricks); + $this->assertNotContains(HeaderCompanyBrick::class, $detailBricks); + } + + #[Test] + public function it_allows_utility_bricks_in_every_band(): void + { + /* Assert */ + foreach (ReportBand::cases() as $band) { + $bandBricks = ReportBricksCollection::forBand($band); + + $this->assertContains(PageBreakBrick::class, $bandBricks, "Page break should be allowed in {$band->value}"); + $this->assertContains(SpacerBrick::class, $bandBricks, "Spacer should be allowed in {$band->value}"); + } + } + + #[Test] + public function it_finds_a_brick_class_by_its_id(): void + { + /* Assert */ + $this->assertSame(HeaderCompanyBrick::class, ReportBricksCollection::findById('header_company')); + $this->assertSame(PageBreakBrick::class, ReportBricksCollection::findById('page_break')); + $this->assertNull(ReportBricksCollection::findById('does_not_exist')); + } + + #[Test] + public function it_all_bricks_are_valid_class_names(): void + { + /* Act */ + $allBricks = ReportBricksCollection::all(); + + /* Assert */ + foreach ($allBricks as $brick) { + $this->assertTrue(class_exists($brick), "Class {$brick} should exist"); + } + } + + #[Test] + public function it_no_duplicate_bricks_in_collection(): void + { + /* Act */ + $allBricks = ReportBricksCollection::all(); + + /* Assert */ + $uniqueBricks = array_unique($allBricks); + $this->assertCount(count($allBricks), $uniqueBricks); + } +} diff --git a/Modules/Core/Tests/Unit/ReportRendererTest.php b/Modules/Core/Tests/Unit/ReportRendererTest.php new file mode 100644 index 000000000..c685d422b --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportRendererTest.php @@ -0,0 +1,124 @@ +renderer = new ReportRenderer(); + } + + #[Test] + public function it_renders_configured_bricks_with_entity_data(): void + { + /* Act */ + $html = $this->renderer->render($this->template([ + 'header' => [['brick' => 'header_company', 'width' => 'half', 'config' => []]], + 'details' => [['brick' => 'detail_items', 'width' => 'full', 'config' => []]], + ]), $this->data()); + + /* Assert */ + $this->assertStringContainsString('ACME Corp', $html); + $this->assertStringContainsString('Widget', $html); + $this->assertStringContainsString('report-band-header', $html); + $this->assertStringContainsString('report-band-details', $html); + } + + #[Test] + public function it_omits_fields_toggled_off_in_the_brick_config(): void + { + /* Act */ + $html = $this->renderer->render($this->template([ + 'header' => [['brick' => 'header_company', 'width' => 'full', 'config' => ['show_vat_id' => false]]], + ]), $this->data()); + + /* Assert */ + $this->assertStringContainsString('ACME Corp', $html); + $this->assertStringNotContainsString('VAT-123', $html); + } + + #[Test] + public function it_skips_bands_without_entries_and_unknown_bricks(): void + { + /* Act */ + $html = $this->renderer->render($this->template([ + 'header' => [['brick' => 'nonexistent_brick', 'width' => 'full', 'config' => []]], + ]), $this->data()); + + /* Assert */ + $this->assertStringNotContainsString('report-band-details', $html); + $this->assertStringNotContainsString('nonexistent', $html); + } + + #[Test] + public function it_marks_keep_together_bands_with_page_break_css(): void + { + /* Act */ + $html = $this->renderer->render($this->template( + ['footer' => [['brick' => 'footer_totals', 'width' => 'full', 'config' => []]]], + ['band_options' => ['footer' => ['keep_together' => true]]], + ), $this->data()); + + /* Assert */ + $this->assertStringContainsString('page-break-inside: avoid', $html); + } + + #[Test] + public function it_renders_manual_page_breaks_and_spacers(): void + { + /* Act */ + $html = $this->renderer->render($this->template([ + 'details' => [ + ['brick' => 'page_break', 'width' => 'full', 'config' => []], + ['brick' => 'spacer', 'width' => 'full', 'config' => ['height' => 55]], + ], + ]), $this->data()); + + /* Assert */ + $this->assertStringContainsString('page-break-after: always', $html); + $this->assertStringContainsString('height: 55px', $html); + } + + #[Test] + public function it_applies_block_widths_as_percentages(): void + { + /* Act */ + $html = $this->renderer->render($this->template([ + 'header' => [['brick' => 'header_company', 'width' => 'half', 'config' => []]], + ]), $this->data()); + + /* Assert */ + $this->assertStringContainsString('width: 50%', $html); + } + + protected function template(array $bands, array $manifest = []): array + { + return [ + 'manifest' => array_merge(['name' => 'Test', 'slug' => 'test', 'type' => 'invoice'], $manifest), + 'bands' => array_merge( + ['header' => [], 'group_header' => [], 'details' => [], 'group_footer' => [], 'footer' => []], + $bands, + ), + ]; + } + + protected function data(): array + { + return [ + 'company' => ['name' => 'ACME Corp', 'vat_id' => 'VAT-123', 'address' => 'Main Street 1'], + 'client' => ['name' => 'Client Co'], + 'invoice' => ['number' => 'INV-001', 'date' => '2026-01-01', 'due_date' => '2026-02-01'], + 'items' => [['description' => 'Widget', 'quantity' => 2, 'price' => '10.00', 'tax' => '4.00', 'total' => '24.00']], + 'totals' => ['subtotal' => '20.00', 'tax' => '4.00', 'total' => '24.00', 'paid' => '0.00', 'balance' => '24.00'], + ]; + } +} diff --git a/Modules/Core/Tests/Unit/ReportTemplateStorageTest.php b/Modules/Core/Tests/Unit/ReportTemplateStorageTest.php new file mode 100644 index 000000000..85176669a --- /dev/null +++ b/Modules/Core/Tests/Unit/ReportTemplateStorageTest.php @@ -0,0 +1,270 @@ +storage = new ReportTemplateStorage(); + + session()->put('current_company_id', 1); + } + + #[Test] + public function it_round_trips_a_saved_template_through_load(): void + { + /* Act */ + $this->storage->save(ReportTemplateStorage::SCOPE_COMPANY, 'test-template', $this->manifest(), $this->bands()); + $loaded = $this->storage->load(ReportTemplateStorage::SCOPE_COMPANY, 'test-template'); + + /* Assert */ + $this->assertNotNull($loaded); + $this->assertSame('Test Template', $loaded['manifest']['name']); + $this->assertSame('header_company', $loaded['bands']['header'][0]['brick']); + $this->assertSame(['show_vat_id' => false], $loaded['bands']['header'][0]['config']); + $this->assertSame('detail_items', $loaded['bands']['details'][0]['brick']); + } + + #[Test] + public function it_skips_unknown_bricks_when_sanitizing_bands(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [ + ['brick' => 'header_company', 'width' => 'half', 'config' => []], + ['brick' => 'evil_unknown_brick', 'width' => 'half', 'config' => []], + ], + ]); + + /* Assert */ + $this->assertCount(1, $sanitized['header']); + $this->assertSame('header_company', $sanitized['header'][0]['brick']); + } + + #[Test] + public function it_skips_bricks_placed_in_a_disallowed_band(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [['brick' => 'detail_items', 'width' => 'full', 'config' => []]], + ]); + + /* Assert */ + $this->assertSame([], $sanitized['header']); + } + + #[Test] + public function it_falls_back_to_full_width_for_invalid_widths(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [['brick' => 'header_company', 'width' => 'gigantic', 'config' => []]], + ]); + + /* Assert */ + $this->assertSame('full', $sanitized['header'][0]['width']); + } + + #[Test] + public function it_filters_brick_config_against_the_brick_schema(): void + { + /* Act */ + $sanitized = $this->storage->sanitizeBands([ + 'header' => [[ + 'brick' => 'header_company', + 'width' => 'half', + 'config' => ['show_vat_id' => true, 'malicious_key' => '