diff --git a/.github/EXPORT-REFACTORING.md b/.github/EXPORT-REFACTORING.md new file mode 100644 index 000000000..42cbd389f --- /dev/null +++ b/.github/EXPORT-REFACTORING.md @@ -0,0 +1,239 @@ +# Export Refactoring - Filament Export Action + +## Overview + +This document outlines the refactoring of export functionality from Maatwebsite/Excel to Filament's built-in Export Action system. + +## Changes Made + +### 1. Created Filament Exporters + +All modules now have dedicated Filament Exporters located in `Modules/{ModuleName}/Filament/Exporters/`: + +**Architecture Improvements:** +- All exporters extend `Modules/Core/Filament/Exporters/BaseExporter` (follows SOLID/DRY principles) +- BaseExporter provides centralized, translatable notification logic +- Each exporter implements abstract `getEntityName()` for dynamic entity naming +- Eliminates code duplication across 18 exporter classes + +**Proper Type Handling:** +- Enum values: Use `->formatStateUsing(fn ($state) => $state?->label() ?? '')` to call label() method +- Date fields: Use `->date()` method for proper date formatting +- Accessor attributes: Explicitly handle with `->formatStateUsing(fn ($state, $record) => $record->accessor_name)` + +**Internationalization:** +- All notification strings use trans() function +- New translation keys in resources/lang/en/ip.php: + - `export_completed` - Success notification + - `export_failed_rows` - Failure notification + - `row` - Pluralizable row/rows + +**Expenses Module:** +- `ExpenseExporter` - Regular export with 7 columns +- `ExpenseLegacyExporter` - Legacy export with 3 columns + +**Products Module:** +- `ProductExporter` - Regular export with 7 columns +- `ProductLegacyExporter` - Legacy export with 3 columns + +**Quotes Module:** +- `QuoteExporter` - Regular export with 8 columns +- `QuoteLegacyExporter` - Legacy export with 6 columns + +**Projects Module:** +- `ProjectExporter` - Regular export with 5 columns +- `ProjectLegacyExporter` - Legacy export with 5 columns + +**Tasks (Projects Module):** +- `TaskExporter` - Regular export with 6 columns +- `TaskLegacyExporter` - Legacy export with 6 columns + +**Clients Module (Relations):** +- `RelationExporter` - Regular export with 11 columns +- `RelationLegacyExporter` - Legacy export with 4 columns + +**Clients Module (Contacts):** +- `ContactExporter` - Regular export with 6 columns +- `ContactLegacyExporter` - Legacy export with 6 columns + +**Invoices Module:** +- `InvoiceExporter` - Regular export with 6 columns +- `InvoiceLegacyExporter` - Legacy export with 4 columns + +**Payments Module:** +- `PaymentExporter` - Regular export with 5 columns +- `PaymentLegacyExporter` - Legacy export with 4 columns + +### 2. Updated List Pages + +The following List Pages were updated to use Filament `ExportAction` instead of custom export services: + +- `Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php` +- `Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php` +- `Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php` +- `Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php` +- `Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php` +- `Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php` +- `Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php` +- `Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php` +- `Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php` + +### 3. Export Actions Available + +Each List Page now has 4 export actions in an action group: + +1. **Export as CSV (v2)** - Uses the regular exporter with CSV format +2. **Export as CSV (v1, Legacy)** - Uses the legacy exporter with CSV format +3. **Export as Excel (v2)** - Uses the regular exporter with XLSX format +4. **Export as Excel (v1, Legacy)** - Uses the legacy exporter with XLSX format + +### 4. Database Migration + +A new migration was added to create the `exports` table required by Filament Export: + +- `Modules/Core/Database/Migrations/2025_11_13_061624_create_exports_table.php` + +Run migrations to apply: +```bash +php artisan migrate +``` + +## Backward Compatibility + +### Preserved Components + +The following components are preserved for backward compatibility: + +1. **All Maatwebsite/Excel Export Classes** (kept in `Modules/{ModuleName}/Exports/`) +2. **All Export Services** (kept in `Modules/{ModuleName}/Services/`) + +These can be deprecated in a future release once the Filament Export system is fully tested and adopted. + +## How Filament Export Works + +### User Experience + +1. User clicks on an export action +2. A modal opens showing available columns to export +3. User can select/deselect columns and customize column labels +4. User clicks "Export" +5. Export job is queued and runs asynchronously +6. User receives a notification when export is complete +7. User can download the exported file from the notification + +### Technical Flow + +1. `ExportAction` creates an `Export` database record +2. Export jobs are dispatched to the queue +3. Jobs process records in chunks (default: 100 rows per chunk) +4. Progress is tracked in the `exports` table +5. On completion, a notification is sent to the user +6. Exported file is stored on configured disk + +### Configuration + +Exporters can be configured in each `*Exporter.php` class: + +- `getColumns()` - Define exportable columns +- `getModel()` - Specify the model being exported +- `getCompletedNotificationBody()` - Customize completion notification +- `getOptionsFormComponents()` - Add custom export options + +## Testing + +### Manual Testing Steps + +For each module (Expenses, Products, Quotes, Projects, Tasks, Relations, Contacts, Invoices, Payments): + +1. Navigate to the list page +2. Click the "Export" button +3. Test each of the 4 export options: + - Export as CSV (v2) + - Export as CSV (v1, Legacy) + - Export as Excel (v2) + - Export as Excel (v1, Legacy) +4. Verify: + - Modal opens with column selection + - Export completes successfully + - Notification is received + - File downloads correctly + - File contains expected data and columns + +### Automated Testing + +**Note:** Filament Export requires comprehensive test rewrite, not simple updates. + +The existing test files are marked as incomplete and need complete rewriting to test Filament Export's asynchronous behavior: + +- `Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php` +- `Modules/Products/Feature/Modules/ProductsExportImportTest.php` +- `Modules/Quotes/Feature/Modules/QuotesExportImportTest.php` +- `Modules/Projects/Feature/Modules/ProjectsExportImportTest.php` +- `Modules/Projects/Feature/Modules/TasksExportImportTest.php` + +**Why tests need complete rewrite:** + +Filament Export fundamentally changes the export flow from synchronous to asynchronous: + +**Old Flow (Maatwebsite/Excel):** +1. User clicks export button +2. Export executes immediately +3. File downloads directly +4. Test: Call action, check response + +**New Flow (Filament Export):** +1. User clicks export button +2. Modal opens for column selection +3. User submits form +4. Export job queued +5. Jobs process asynchronously +6. Notification sent on completion +7. User downloads from notification + +**Test Requirements:** +- Mock/fake queue system +- Test Livewire modal interactions +- Verify job dispatching +- Check database records in exports table +- Validate notification delivery +- Test file generation and storage +- Verify column selection functionality + +This is a significant undertaking beyond the scope of export refactoring. Tests are documented for future implementation. + +## Future Improvements + +1. **Deprecate Export Services**: Once Filament Export is fully tested, the old export services can be removed +2. **Update Tests**: Rewrite export tests to work with Filament's asynchronous export system +3. **Custom Export Options**: Add filtering, date ranges, and other export options via `getOptionsFormComponents()` +4. **Scheduled Exports**: Implement recurring exports using Filament's export scheduling features +5. **Export Templates**: Allow users to save preferred export configurations + +## Troubleshooting + +### Queue Configuration + +Filament Export uses Laravel's queue system. Ensure your queue is configured: + +```bash +# Start queue worker +php artisan queue:work +``` + +### Storage Configuration + +Exports are stored using Laravel's filesystem. Ensure your storage is configured in `config/filesystems.php`. + +### Permission Issues + +Ensure the `exports` table exists and migrations have been run: + +```bash +php artisan migrate +``` + +## References + +- [Filament Export Documentation](https://filamentphp.com/docs/4.x/actions/export) +- [Laravel Queue Documentation](https://laravel.com/docs/queues) +- [Maatwebsite/Excel Documentation](https://docs.laravel-excel.com) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5374caa71..51630b3e1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,6 +22,7 @@ This project is **InvoicePlane v2**, a **multi-tenant Laravel application** with - **Module System:** nwidart/laravel-modules - **Permissions:** spatie/laravel-permission - **Multi-tenancy:** Filament Companies with `BelongsToCompany` trait +- **Queue System:** Required for export functionality (Redis, database, or sync for local development) ## Development Commands @@ -57,8 +58,16 @@ composer install cp .env.example .env php artisan key:generate php artisan migrate --seed + +# Start queue worker for export functionality +php artisan queue:work ``` +**Queue Configuration:** +- Export functionality requires a queue worker to be running +- For local development, you can use `QUEUE_CONNECTION=sync` in `.env` +- For production, use Redis or database queue driver with Supervisor + ## Related Documentation - **Installation:** `.github/INSTALLATION.md` @@ -124,6 +133,15 @@ php artisan migrate --seed - Reusable logic (e.g., fixtures, setup) must live in abstract test cases, not inline. - Tests have inline comment blocks above sections (Arrange, Act, Assert). +### Export System Rules + +- **Exports use Filament's asynchronous export system** which requires queue workers. +- **Export tests must use fakes:** `Queue::fake()`, `Storage::fake()`, and verify job dispatching with `Bus::assertChained()`. +- **The `exports` table is temporary** and managed by Filament for job coordination only. +- **No export history feature** - export records are ephemeral and auto-prunable. +- **Queue configuration is required** for export functionality to work in production. +- See `Modules/Core/Filament/Exporters/README.md` for export architecture details. + ### Database & Models - **No `$fillable` array in Models.** diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000..41344e9cb --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,198 @@ +# GitHub Actions Workflows + +This directory contains GitHub Actions workflows for automated CI/CD tasks. + +## Available Workflows + +### 1. Production Release (`release.yml`) + +**Trigger:** Automatically runs on every push to the `master` branch + +**Purpose:** Creates a production-ready release package of InvoicePlane v2 and publishes it as a GitHub Release + +**What it does:** +1. **Downloads translations from Crowdin** - Retrieves the latest translations +2. **Builds frontend assets** - Runs `yarn install --frozen-lockfile && yarn build` +3. **Installs PHP dependencies** - Runs `composer install --no-dev` for production +4. **Cleans up node_modules** - Removes Node.js dependencies +5. **Optimizes vendor directory** - Removes unnecessary files (tests, docs, etc.) +6. **Creates release archive** - Packages everything into a timestamped ZIP file +7. **Generates version tag** - Creates a new version tag (alpha/beta/stable) +8. **Creates GitHub Release** - Publishes release with changelog and artifacts + +**Release Types:** + +The workflow supports configurable release types (set in workflow file): +- `alpha` - Pre-release versions (increments patch, adds -alpha suffix) +- `beta` - Beta versions (increments patch, adds -beta suffix) +- `stable` - Stable releases (increments minor version) + +To change the release type, edit the `RELEASE_TYPE` environment variable at the top of `release.yml`. + +**Versioning:** + +The workflow automatically: +- Detects the latest tag (or starts from v0.0.0) +- Increments version based on release type +- Creates a new tag (e.g., v0.1.0-alpha, v0.2.0-beta, v1.0.0) +- Generates release notes showing changes since the previous tag + +**Security:** + +The workflow uses minimal permissions: +- `contents: write` - Required for creating releases and tags +- `actions: write` - Required for uploading workflow artifacts + +**Required Secrets:** + +Before using this workflow, you need to configure these GitHub secrets: + +- `CROWDIN_PROJECT_ID` - Your Crowdin project ID +- `CROWDIN_PERSONAL_TOKEN` - Your Crowdin personal access token + +To add these secrets: +1. Go to your repository Settings +2. Navigate to Secrets and variables → Actions +3. Click "New repository secret" +4. Add each secret with its corresponding value + +**Crowdin Setup:** + +To get your Crowdin credentials: +1. Log in to [Crowdin](https://crowdin.com/) +2. Navigate to your InvoicePlane project +3. Go to Settings → API +4. Generate a Personal Access Token +5. Copy your Project ID from the project settings + +**Accessing Releases:** + +After the workflow runs: +1. Go to the **Releases** section of your repository +2. Find the latest release (e.g., "Release v0.1.0-alpha") +3. Download the ZIP file and checksums from the release assets +4. Review the automated changelog + +Artifacts are also available in the Actions tab for 90 days. + +### 2. PHPUnit Tests (`phpunit.yml`) + +**Trigger:** Manual dispatch only + +Runs the PHPUnit test suite against a MySQL database. + +### 3. Laravel Pint (`pint.yml`) + +**Trigger:** Manual dispatch only + +Runs Laravel Pint for code formatting checks. + +### 4. PHPStan (`phpstan.yml`) + +**Trigger:** Manual dispatch only + +Runs PHPStan static analysis. + +### 5. Docker Compose Check (`docker.yml`) + +**Trigger:** Manual dispatch only + +Tests Docker Compose configuration. + +### 6. Quickstart (`quickstart.yml`) + +**Trigger:** Manual dispatch only + +Provides a quick setup for development environments. + +## Workflow Optimization + +### Vendor Directory Cleanup + +The release workflow aggressively cleans the vendor directory to minimize file size: + +- Removes all test directories (`tests`, `Tests`, `test`, `Test`) +- Removes all documentation (`docs`, `doc`, `*.md`, `*.txt`) +- Removes all Git metadata (`.git`, `.gitignore`, `.gitattributes`) +- Removes build files (`composer.json`, `composer.lock`, `phpunit.xml`, etc.) +- Removes code quality files (`.php_cs`, `phpstan.neon`, etc.) + +This typically reduces the vendor directory size by 40-60%. + +### ZIP Exclusions + +The following files and directories are excluded from the release archive: + +- Development files: `.github/*`, `tests/*`, `README.md` +- Configuration files: `phpunit.xml`, `phpstan.neon`, `pint.json`, `rector.php` +- Build tools: `package.json`, `yarn.lock`, `vite.config.js`, `tailwind.config.js` +- Docker files: `docker-compose.yml` +- Environment files: `.env*` +- Storage: `storage/logs/*`, `storage/framework/cache/*` +- Node modules: `node_modules/*` (already removed in cleanup step) + +## Troubleshooting + +### Crowdin Download Fails + +If the Crowdin step fails, check: +1. Secrets are correctly configured +2. Your Crowdin personal token has not expired +3. The project ID is correct +4. Your Crowdin project is properly configured + +### Build Fails + +If the frontend build fails: +1. Ensure `package.json` is up to date +2. Check for syntax errors in Vite/Tailwind config +3. Verify all dependencies are correctly specified + +### Composer Install Fails + +If Composer installation fails: +1. Check `composer.json` for syntax errors +2. Ensure all required PHP extensions are available +3. Verify package versions are compatible + +## Customization + +### Changing PHP Version + +Edit line 49 in `release.yml`: +```yaml +php-version: '8.3' # Using 8.3 for latest features; composer.json requires ^8.2 +``` + +### Changing Node.js Version + +Edit line 36 in `release.yml`: +```yaml +node-version: '20' # Change to your desired version +``` + +### Adjusting Artifact Retention + +Edit line 121 in `release.yml`: +```yaml +retention-days: 90 # Change to your desired retention period (1-90 days) +``` + +### Custom ZIP Exclusions + +Add or remove exclusions in the "Create release zip" step (lines 86-110). + +## Best Practices + +1. **Test locally first** - Before relying on the workflow, test the build process locally +2. **Monitor workflow runs** - Check the Actions tab regularly for failures +3. **Keep secrets secure** - Never commit secrets to the repository +4. **Update dependencies** - Keep GitHub Actions and dependencies up to date +5. **Tag releases** - Use semantic versioning for production releases + +## Support + +For issues or questions about these workflows: +- Create an issue in the repository +- Join the [Community Forums](https://community.invoiceplane.com) +- Visit the [Discord server](https://discord.gg/PPzD2hTrXt) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..b172fcfaa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,314 @@ +name: Build Production Release + +on: + push: + branches: + - master + +env: + RELEASE_TYPE: 'alpha' # Options: alpha, beta, stable + +jobs: + build-release: + name: Build and Package Production Release + runs-on: ubuntu-latest + + permissions: + contents: write # Required for creating releases and tags + actions: write # Required for uploading artifacts + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 # Fetch only last commit for faster checkout + fetch-tags: true # Fetch tags for versioning + + # Step 1: Download translations from Crowdin + - name: Download translations from Crowdin + uses: crowdin/github-action@v2 + with: + download_translations: true + localization_branch_name: master + create_pull_request: false + crowdin_branch_name: master + config: 'crowdin.yml' + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # Step 2: Validate translations + - name: Validate translations directory + run: | + echo "Validating translations structure..." + if [ ! -d "resources/lang/en" ]; then + echo "ERROR: Missing required language directory: resources/lang/en" + exit 1 + fi + echo "✓ Translations structure validated" + + # Step 3: Set up Node.js for frontend build + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install frontend dependencies (production mode) + run: | + echo "Installing frontend dependencies..." + yarn install --frozen-lockfile --production=false + echo "✓ Frontend dependencies installed" + + - name: Build frontend assets for production + run: | + echo "Building frontend assets..." + yarn build + echo "✓ Frontend assets built successfully" + + # Step 4: Set up PHP and Composer + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: gd, bcmath, dom, intl, xml, zip, mbstring, pdo_mysql + coverage: none + + - name: Display tool versions + run: | + echo "=========================================" + echo "Tool Versions" + echo "=========================================" + echo "PHP version: $(php -v | head -n 1)" + echo "Composer version: $(composer --version)" + echo "Node.js version: $(node -v)" + echo "npm version: $(npm -v)" + echo "Yarn version: $(yarn -v)" + echo "=========================================" + + - name: Install Composer dependencies (production) + run: | + echo "Installing Composer dependencies (production mode)..." + composer install --no-dev --optimize-autoloader \ + --no-interaction --prefer-dist + echo "✓ Composer dependencies installed" + + - name: Install vendor cleaner and optimize + run: | + echo "Installing composer-vendor-cleaner..." + composer require liborm85/composer-vendor-cleaner --dev \ + --no-interaction --no-update + composer update liborm85/composer-vendor-cleaner --no-interaction + echo "Running vendor cleanup..." + composer vendor-cleaner + echo "✓ Vendor directory optimized" + + # Step 5: Cleanup workspace + - name: Clean workspace + run: | + echo "=========================================" + echo "Cleaning Workspace" + echo "=========================================" + + echo "Removing npm dependencies (node_modules)..." + rm -rf node_modules + + echo "Removing .DS_Store files..." + find . -type f -name '.DS_Store' -delete + + echo "Cleaning mPDF fonts (keeping only DejaVu)..." + find vendor/mpdf/mpdf/ttfonts -type f ! -name "DejaVu*" \ + -delete 2>/dev/null || true + + echo "Cleaning mPDF QR code data..." + rm -rf vendor/mpdf/mpdf/src/QrCode/data/* 2>/dev/null || true + + echo "Removing .git directories from vendor..." + find vendor -name '.git' -type d -exec rm -rf {} + 2>/dev/null || true + + echo "Removing .github directories..." + rm -rf .github + + echo "✓ Workspace cleaned successfully" + echo "=========================================" + + # Step 6: Create release archive + - name: Package InvoicePlane + run: | + echo "=========================================" + echo "Creating Release Package" + echo "=========================================" + + # Create a timestamp for the release + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + RELEASE_NAME="invoiceplane-v2-${TIMESTAMP}" + RELEASE_FILE="${RELEASE_NAME}.zip" + + echo "Package name: ${RELEASE_FILE}" + + # Create zip excluding unnecessary files + echo "Creating ZIP archive (this may take a moment)..." + zip -q -r -9 "${RELEASE_FILE}" . \ + -x "*.git*" \ + -x "node_modules/*" \ + -x "tests/*" \ + -x ".env*" \ + -x "*.sqlite" \ + -x "storage/logs/*" \ + -x "storage/framework/cache/*" \ + -x "storage/framework/sessions/*" \ + -x "storage/framework/views/*" \ + -x ".phpunit*" \ + -x "phpunit.xml" \ + -x "phpstan.neon" \ + -x "phpstan-baseline.neon" \ + -x "pint.json" \ + -x "rector.php" \ + -x ".editorconfig" \ + -x ".prettierrc" \ + -x "docker-compose.yml" \ + -x "yarn.lock" \ + -x "package.json" \ + -x "vite.config.js" \ + -x "tailwind.config.js" \ + -x ".DS_Store" + + echo "✓ Package created successfully" + + echo "Generating checksums..." + sha256sum "${RELEASE_FILE}" > sha256.txt + md5sum "${RELEASE_FILE}" > md5.txt + + echo "SHA256: $(cat sha256.txt)" + echo "MD5: $(cat md5.txt)" + echo "✓ Checksums generated" + + echo "RELEASE_NAME=${RELEASE_NAME}" >> $GITHUB_ENV + echo "RELEASE_FILE=${RELEASE_FILE}" >> $GITHUB_ENV + + echo "=========================================" + echo "Package Details" + echo "=========================================" + ls -lh "${RELEASE_FILE}" + echo "=========================================" + + # Step 7: Generate version tag and release notes + - name: Generate version tag and release notes + id: version + run: | + echo "=========================================" + echo "Generating Version Tag" + echo "=========================================" + + # Get the latest tag or start with v0.0.0 + PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Previous tag: ${PREVIOUS_TAG}" + + # Extract version numbers + VERSION=${PREVIOUS_TAG#v} + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + + # Increment version based on release type + case "${{ env.RELEASE_TYPE }}" in + alpha) + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-alpha" + ;; + beta) + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-beta" + ;; + stable) + MINOR=$((MINOR + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.0" + ;; + *) + echo "ERROR: Invalid RELEASE_TYPE: ${{ env.RELEASE_TYPE }}" + exit 1 + ;; + esac + + NEW_TAG="v${NEW_VERSION}" + echo "New tag: ${NEW_TAG}" + + # Store for later steps + echo "NEW_TAG=${NEW_TAG}" >> $GITHUB_ENV + echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_ENV + echo "new_tag=${NEW_TAG}" >> $GITHUB_OUTPUT + + echo "✓ Version tag generated" + echo "=========================================" + + # Generate release notes between tags + echo "Generating release notes..." + echo "# Release ${NEW_TAG}" > RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + echo "**Release Type:** ${{ env.RELEASE_TYPE }}" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + + if [ "$PREVIOUS_TAG" = "v0.0.0" ]; then + echo "## Initial Release" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + echo "This is the first release of InvoicePlane v2." >> RELEASE_NOTES.md + else + echo "## Changes since ${PREVIOUS_TAG}" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + + # Fetch previous tag for comparison + git fetch --depth=1 origin tag ${PREVIOUS_TAG} 2>/dev/null || true + + # Generate changelog + git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" >> RELEASE_NOTES.md 2>/dev/null || \ + echo "- Initial release" >> RELEASE_NOTES.md + fi + + echo "" >> RELEASE_NOTES.md + echo "---" >> RELEASE_NOTES.md + echo "Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> RELEASE_NOTES.md + + echo "✓ Release notes generated" + cat RELEASE_NOTES.md + + # Step 8: Upload release artifact + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.RELEASE_NAME }} + path: | + ${{ env.RELEASE_FILE }} + sha256.txt + md5.txt + RELEASE_NOTES.md + retention-days: 90 + + # Step 9: Create GitHub Release + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ env.NEW_TAG }} + name: Release ${{ env.NEW_TAG }} + body_path: RELEASE_NOTES.md + files: | + ${{ env.RELEASE_FILE }} + sha256.txt + md5.txt + draft: false + prerelease: ${{ env.RELEASE_TYPE != 'stable' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Step 10: Workflow complete + - name: Workflow summary + run: | + echo "=========================================" + echo "INVOICEPLANE V2 WORKFLOW COMPLETED" + echo "=========================================" + echo "Release: ${{ env.NEW_TAG }}" + echo "Type: ${{ env.RELEASE_TYPE }}" + echo "Package: ${{ env.RELEASE_FILE }}" + echo "Artifact name: ${{ env.RELEASE_NAME }}" + echo "Checksums: sha256.txt, md5.txt" + echo "Release notes: RELEASE_NOTES.md" + echo "=========================================" + echo "GitHub Release created successfully!" + echo "=========================================" diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 000000000..eb31b0a19 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,472 @@ +# Junie AI Agent Guidelines for InvoicePlane v2 + +This document provides comprehensive guidelines for AI agents (like Junie) working on the InvoicePlane v2 codebase to ensure maximum information accuracy and performance. + +--- + +## 🎯 Project Overview + +**InvoicePlane v2** is a multi-tenant invoicing and billing application built with modern PHP/Laravel technologies. + +### Core Architecture +- **Framework:** Laravel 12+ (PHP 8.2+) +- **UI:** Filament 4.0 (Admin/Company/Invoice panels) +- **Frontend:** Livewire + Tailwind CSS +- **Module System:** nwidart/laravel-modules (modular monolith) +- **Multi-tenancy:** Filament Companies with `BelongsToCompany` trait +- **Permissions:** spatie/laravel-permission +- **Queue System:** Required for export functionality + +### Module Structure +``` +Modules/ +├── ModuleName/ +│ ├── Models/ # Eloquent models +│ ├── Services/ # Business logic layer +│ ├── Repositories/ # Data access layer +│ ├── DTOs/ # Data Transfer Objects +│ ├── Transformers/ # DTO ↔ Model transformations +│ ├── Filament/ # Filament resources (Admin/Company panels) +│ ├── Tests/ # PHPUnit tests +│ └── Database/ # Migrations, seeders, factories +``` + +--- + +## 📋 Critical Principles (MUST FOLLOW) + +### 1. SOLID Principles +- **Single Responsibility:** Each class has one clear purpose +- **Open/Closed:** Extend behavior without modifying existing code +- **Liskov Substitution:** Subtypes must be substitutable for base types +- **Interface Segregation:** No fat interfaces; clients shouldn't depend on unused methods +- **Dependency Inversion:** Depend on abstractions, not concretions + +### 2. Code Quality Standards +- **Early Returns:** Prefer early returns over nested conditions +- **No Inline Logic:** Business logic must be in services, not controllers/resources +- **Dynamic Programming:** Apply where relevant (memoization, tabulation) +- **Centralize Shared Logic:** Use traits to avoid duplication +- **Type Safety:** Use native PHP type hints throughout + +### 3. Error Handling +```php +// Catch specific exceptions separately +try { + // code +} catch (Error $e) { + // Handle Error +} catch (ErrorException $e) { + // Handle ErrorException +} catch (Throwable $e) { + // Handle other throwables +} +``` + +--- + +## 🏗️ Architecture Patterns + +### DTO & Transformer Rules + +**DTOs (Data Transfer Objects):** +- ❌ NO constructors in DTOs +- ✅ Use static named constructors when necessary +- ✅ Rely on getters and setters for data access +- ✅ DTOs are transformed using Transformers + +**Transformers:** +- Must implement `toDto()` and `toModel()` methods +- Services must use Transformers directly (not build DTOs manually) +- EntityExtractionService must use Transformers for entire transformation process + +**Example:** +```php +// DTO +class InvoiceDTO +{ + private string $number; + private float $total; + + // No constructor! + + public static function fromArray(array $data): self + { + $dto = new self(); + $dto->setNumber($data['number']); + $dto->setTotal($data['total']); + return $dto; + } + + public function getNumber(): string { return $this->number; } + public function setNumber(string $number): void { $this->number = $number; } +} + +// Transformer +class InvoiceTransformer +{ + public function toDto(Invoice $model): InvoiceDTO + { + return InvoiceDTO::fromArray([ + 'number' => $model->number, + 'total' => $model->total, + ]); + } + + public function toModel(InvoiceDTO $dto): Invoice + { + $model = new Invoice(); + $model->number = $dto->getNumber(); + $model->total = $dto->getTotal(); + return $model; + } +} +``` + +### Service Layer +- All business logic must be in services +- Services coordinate between repositories, transformers, and external systems +- Services must not build DTOs manually—use Transformers +- Services return DTOs or collections of DTOs + +### Repository Layer +- Repositories handle data access only +- Use repository methods for upserts (not `updateOrCreate`) +- Repositories return models or collections of models + +### API Integration +- All API requests must go through the Advanced API Client +- No direct API calls in controllers, services, or jobs +- Use Laravel's HTTP client (not curl or Guzzle) +- All transformations must go through Transformers +- API responses and errors must be logged separately + +--- + +## 🧪 Testing Standards + +### Test Structure +```php +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\Group; + +class InvoiceServiceTest extends AbstractCompanyPanelTestCase +{ + use RefreshDatabase; + + #[Test] + #[Group('invoices')] + public function it_creates_invoice_with_valid_data(): void + { + /* Arrange */ + $data = ['number' => 'INV-001', 'total' => 100.00]; + + /* Act */ + $result = $this->service->createInvoice($data); + + /* Assert */ + $this->assertInstanceOf(InvoiceDTO::class, $result); + $this->assertEquals('INV-001', $result->getNumber()); + } +} +``` + +### Testing Rules (MANDATORY) +1. **Test Naming:** Functions prefixed with `it_` (e.g., `it_creates_invoice`) +2. **No `@test` Annotations:** Use `#[Test]` attribute instead +3. **Prefer Fakes over Mocks:** + ```php + Queue::fake(); + Storage::fake('local'); + Notification::fake(); + ``` +4. **Happy Paths Last:** Place success scenarios at the end +5. **Reusable Setup:** Abstract test cases for fixtures, not inline +6. **Comment Blocks:** Use `/* Arrange */`, `/* Act */`, `/* Assert */` + +### Export Testing +```php +#[Test] +#[Group('export')] +public function it_dispatches_csv_export_job(): void +{ + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $records = Model::factory()->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListPage::class) + ->callAction('exportCsv', data: [ + 'columnMap' => [ + 'field' => ['isEnabled' => true, 'label' => 'Label'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + fn($batch) => $batch instanceof \Illuminate\Bus\PendingBatch + ]); +} +``` + +--- + +## 🗄️ Database & Models + +### Migration Rules +- ❌ NO JSON columns in migrations +- ❌ NO ENUM columns in migrations +- ❌ NO `timestamps()` unless explicitly specified +- ❌ NO `softDeletes()` unless explicitly specified + +### Model Rules +- ❌ NO `$fillable` array in models +- ❌ NO `timestamps` or `softDeletes` properties unless needed +- ✅ Use native PHP type hints +- ✅ Use `$casts` for Enum fields + +```php +class Invoice extends Model +{ + // No $fillable! + + protected $casts = [ + 'status' => InvoiceStatus::class, // Enum + 'total' => 'decimal:2', + 'issued_at' => 'datetime', + ]; +} +``` + +--- + +## 🎨 Filament Resources + +### Resource Generation +- Must use Filament internal traits (`CanReadModelSchemas`, etc.) +- No reflection for relationship detection +- Separate form and table generators by field type +- Keep configurable `$excludedFields` array +- Detect Enums via `$casts` and `enum_exists()` +- Add docblocks above `form()`, `table()`, `getRelations()` +- Use `copyStubToApp()` instead of inline string replacements + +### Panel Separation +- Respect proper panel namespaces (Admin/Company/Invoice) +- Resources in correct panel directories +- Preserve exact method signatures + +### Best Practices +- Use correct `Action::make()` syntax with fluent methods +- Don't display raw `created_at` or `updated_at` in tables/infolists +- Use dedicated timestamp columns instead + +--- + +## 📤 Export System + +### Architecture +- Exports use Filament's asynchronous export system +- **Requires queue workers** to be running +- The `exports` table is temporary (job coordination only) +- NO export history feature +- Auto-prunable via Laravel's model pruning + +### Queue Configuration + +**Local Development:** +```bash +# Option 1: Sync driver (blocks request) +QUEUE_CONNECTION=sync + +# Option 2: Queue worker +php artisan queue:work +``` + +**Production:** +```bash +# Redis (recommended) +QUEUE_CONNECTION=redis + +# With Supervisor +[program:invoiceplane-worker] +command=php /path/to/artisan queue:work --sleep=3 --tries=3 +``` + +### Export Test Requirements +- Must use `Queue::fake()` and `Storage::fake()` +- Verify job dispatching with `Bus::assertChained()` +- Don't test file content (test job dispatch only) +- See: `Modules/Core/Filament/Exporters/README.md` + +--- + +## 🔐 Security & Permissions + +### Seeding Rules +- Seed 5 default roles: `superadmin`, `admin`, `assistance`, `useradmin`, `user` +- Users can belong to accounts (multi-tenancy) +- Admin Panel access restricted to `admin` and `superadmin` + +### Multi-tenancy +- Use `BelongsToCompany` trait on models +- Company context required for all user operations +- Filament panels enforce tenant isolation + +--- + +## 🛠️ Development Workflow + +### Commands + +**Testing:** +```bash +php artisan test # All tests +php artisan test --coverage # With coverage +php artisan test --testsuite=Unit # Unit tests only +php artisan test --group=export # Export tests only +``` + +**Code Quality:** +```bash +vendor/bin/pint # Format code (PSR-12) +vendor/bin/phpstan analyse # Static analysis +vendor/bin/rector process --dry-run # Refactoring suggestions +``` + +**Setup:** +```bash +composer install +cp .env.example .env +php artisan key:generate +php artisan migrate --seed +php artisan queue:work # For exports +``` + +### Git Commit Conventions +- Follow conventions in `.github/git-commit-instructions.md` +- Use semantic commit messages +- Reference issues when applicable + +--- + +## 📚 Documentation References + +### Key Documentation Files +- **Installation:** `.github/INSTALLATION.md` +- **Contributing:** `.github/CONTRIBUTING.md` +- **Testing:** Module tests in `Modules/*/Tests/` +- **Seeding:** `.github/SEEDING.md` +- **Commits:** `.github/git-commit-instructions.md` +- **Export Architecture:** `Modules/Core/Filament/Exporters/README.md` +- **Module Checklist:** `CHECKLIST.md` + +### Related Documentation +- Laravel 12: https://laravel.com/docs/12.x +- Filament 4: https://filamentphp.com/docs/4.x +- Livewire 3: https://livewire.laravel.com/docs +- PHPUnit 11: https://docs.phpunit.de/en/11.0/ + +--- + +## ⚡ Performance Optimization + +### Query Optimization +- Use eager loading to prevent N+1 queries +- Index foreign keys and frequently queried columns +- Use `select()` to limit columns when possible +- Chunk large datasets for processing + +### Caching Strategy +- Cache expensive computations +- Use Redis for session and cache storage +- Implement query result caching where appropriate + +### Queue Workers +- Use multiple workers for high-volume operations +- Configure max execution time appropriately +- Monitor failed jobs and retry logic + +--- + +## 🚫 Common Pitfalls to Avoid + +1. ❌ Don't use `$fillable` in models +2. ❌ Don't create DTOs with constructors +3. ❌ Don't build DTOs manually in services—use Transformers +4. ❌ Don't use JSON or ENUM columns in migrations +5. ❌ Don't add timestamps/softDeletes unless specified +6. ❌ Don't test export file content—test job dispatching +7. ❌ Don't make direct API calls—use Advanced API Client +8. ❌ Don't use `updateOrCreate`—use repository upsert methods +9. ❌ Don't nest conditions deeply—use early returns +10. ❌ Don't duplicate logic—centralize in traits + +--- + +## ✅ Code Review Checklist + +Before submitting code, verify: + +- [ ] Follows SOLID principles +- [ ] No inline business logic (in services) +- [ ] DTOs use static constructors, not `__construct()` +- [ ] Transformers used for DTO ↔ Model conversions +- [ ] Tests use `it_` prefix and `#[Test]` attribute +- [ ] Tests have Arrange/Act/Assert comments +- [ ] No `$fillable` in models +- [ ] No JSON/ENUM in migrations +- [ ] Type hints used throughout +- [ ] Early returns instead of nested conditions +- [ ] Fakes used instead of mocks in tests +- [ ] Export tests use Queue/Storage fakes +- [ ] Code formatted with `vendor/bin/pint` +- [ ] Static analysis passes (`vendor/bin/phpstan`) +- [ ] All tests pass (`php artisan test`) +- [ ] Documentation updated if needed + +--- + +## 🎓 Learning Resources + +### InvoicePlane-Specific +- Review existing modules for patterns +- Check test files for examples +- Read module-specific README files +- Follow CHECKLIST.md for feature status + +### Laravel/PHP +- [Laravel Best Practices](https://github.com/alexeymezenin/laravel-best-practices) +- [PHP: The Right Way](https://phptherightway.com/) +- [SOLID Principles in PHP](https://solidprinciples.dev/) + +### Filament +- [Filament Tricks](https://filamentphp.com/tricks) +- [Filament Community](https://github.com/filamentphp) + +--- + +## 🔄 Continuous Improvement + +This document should be updated as: +- New patterns emerge +- Architecture decisions change +- Best practices evolve +- Performance optimizations discovered + +**Last Updated:** 2025-11-13 + +--- + +## 📞 Support + +- **Discord:** https://discord.gg/PPzD2hTrXt +- **Forums:** https://community.invoiceplane.com +- **Issues:** https://github.com/InvoicePlane/InvoicePlane/issues +- **Wiki:** https://wiki.invoiceplane.com + +--- + +**Remember:** These guidelines ensure consistency, maintainability, and performance across the InvoicePlane v2 codebase. When in doubt, refer to existing code that follows these patterns, and always prioritize code quality over speed of delivery. diff --git a/Modules/Clients/Exports/ContactsExport.php b/Modules/Clients/Exports/ContactsExport.php new file mode 100644 index 000000000..948254ffa --- /dev/null +++ b/Modules/Clients/Exports/ContactsExport.php @@ -0,0 +1,47 @@ +contacts = $contacts; + } + + public function collection(): Collection + { + return $this->contacts; + } + + public function headings(): array + { + return [ + trans('ip.relation_id'), + trans('ip.type'), + trans('ip.contact_name'), + trans('ip.email'), + trans('ip.phone'), + trans('ip.gender'), + ]; + } + + public function map($row): array + { + return [ + $row->relation?->trading_name ?? $row->relation?->company_name ?? '', + $row->relation?->relation_type?->label() ?? '', + $row->full_name, + $row->email ?? null, + $row->phone ?? null, + $row->gender, + ]; + } +} diff --git a/Modules/Clients/Exports/ContactsLegacyExport.php b/Modules/Clients/Exports/ContactsLegacyExport.php new file mode 100644 index 000000000..91b9937eb --- /dev/null +++ b/Modules/Clients/Exports/ContactsLegacyExport.php @@ -0,0 +1,47 @@ +contacts = $contacts; + } + + public function collection(): Collection + { + return $this->contacts; + } + + public function headings(): array + { + return [ + trans('ip.relation_id'), + trans('ip.type'), + trans('ip.contact_name'), + trans('ip.email'), + trans('ip.phone'), + trans('ip.gender'), + ]; + } + + public function map($row): array + { + return [ + $row->relation?->trading_name ?? $row->relation?->company_name ?? '', + $row->relation?->relation_type?->label() ?? '', + $row->full_name, + $row->email ?? null, + $row->phone ?? null, + $row->gender, + ]; + } +} diff --git a/Modules/Clients/Exports/RelationsExport.php b/Modules/Clients/Exports/RelationsExport.php new file mode 100644 index 000000000..d58c3f2e3 --- /dev/null +++ b/Modules/Clients/Exports/RelationsExport.php @@ -0,0 +1,57 @@ +relations = $relations; + } + + public function collection(): Collection + { + return $this->relations; + } + + public function headings(): array + { + return [ + trans('ip.primary_contact'), + trans('ip.relation_type'), + trans('ip.relation_status'), + trans('ip.relation_number'), + trans('ip.company_name'), + trans('ip.unique_name'), + trans('ip.coc_number'), + trans('ip.vat_number'), + trans('ip.language'), + trans('ip.email'), + trans('ip.phone'), + ]; + } + + public function map($row): array + { + return [ + $row->primary_contact, + $row->relation_type?->label() ?? '', + $row->relation_status?->label() ?? '', + $row->relation_number, + $row->company_name, + $row->unique_name, + $row->coc_number, + $row->vat_number, + $row->language, + $row->email ?? null, + $row->phone ?? null, + ]; + } +} diff --git a/Modules/Clients/Exports/RelationsLegacyExport.php b/Modules/Clients/Exports/RelationsLegacyExport.php new file mode 100644 index 000000000..0db944bb2 --- /dev/null +++ b/Modules/Clients/Exports/RelationsLegacyExport.php @@ -0,0 +1,43 @@ +relations = $relations; + } + + public function collection(): Collection + { + return $this->relations; + } + + public function headings(): array + { + return [ + trans('ip.relation_type'), + trans('ip.trading_name'), // or company_name if trading_name is not set + trans('ip.email'), + trans('ip.phone'), + ]; + } + + public function map($row): array + { + return [ + $row->relation_type?->label() ?? '', + $row->trading_name ?? $row->company_name, + $row->email, + $row->phone, + ]; + } +} diff --git a/Modules/Clients/Feature/Modules/ClientsExportImportTest.php b/Modules/Clients/Feature/Modules/ClientsExportImportTest.php index 58983336a..89f605f0f 100644 --- a/Modules/Clients/Feature/Modules/ClientsExportImportTest.php +++ b/Modules/Clients/Feature/Modules/ClientsExportImportTest.php @@ -3,9 +3,12 @@ namespace Modules\Clients\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; -use Modules\Clients\Filament\Company\Resources\Contacts\Pages\ListContacts; -use Modules\Clients\Models\Contact; +use Modules\Clients\Filament\Company\Resources\Clients\Pages\ListClients; +use Modules\Clients\Models\Client; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; @@ -16,256 +19,159 @@ class ClientsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function export_contacts_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { /* Arrange */ - $contacts = Contact::factory()->for($this->company)->count(3)->create(); + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($contacts->count() + 1, $lines); - foreach ($contacts as $contact) { - $this->assertStringContainsString($contact->name, $content); - } - } - - #[Test] - #[Group('export')] - public function export_contacts_downloads_excel_with_correct_data(): void - { - /* Arrange */ - $contacts = Contact::factory()->for($this->company)->count(3)->create(); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('export', ['format' => 'xlsx']) - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - // Check for XLSX file signature (PK\x03\x04) - $this->assertStringStartsWith('PK', $content); - } - - #[Test] - #[Group('export')] - public function export_contacts_with_no_records(): void - { - /* Arrange */ - // No contacts created - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); // Only header row + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_contacts_with_special_characters(): void + public function it_dispatches_excel_export_job_v2(): void { /* Arrange */ - $contacts = Contact::factory()->for($this->company)->for($this->company)->create(['name' => 'Jöhn Dœ, "Test"', 'email' => 'special@example.com']); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('Jöhn Dœ', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('special@example.com', $content); - } - - #[Test] - #[Group('import')] - public function import_contacts_with_empty_file(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', ''); + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->test(ListClients::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('contacts', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_contacts_with_only_headers(): void + #[Group('export')] + public function it_exports_with_no_records(): void { /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', "name,email\n"); + Queue::fake(); + Storage::fake('local'); + // No clients created /* Act */ Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->test(ListClients::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('contacts', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_contacts_with_invalid_columns(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', "foo,bar\nabc,def\n"); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('contacts', 0); - // Optionally, assert error message if your import action provides one - } - - #[Test] - #[Group('import')] - public function import_contacts_with_duplicate_records(): void + #[Group('export')] + public function it_exports_with_special_characters(): void { /* Arrange */ - $csv = "name,email\nDup User,dup@example.com\nDup User,dup@example.com\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $client = Client::factory()->for($this->company)->create([ + 'company_name' => 'ÜClient, "Test"', + ]); /* Act */ Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('contacts', 2); // or 1 if your import deduplicates - } - - #[Test] - #[Group('import')] - public function import_contacts_with_invalid_data_types(): void - { - /* Arrange */ - $csv = "name,email\n12345,not-an-email\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->test(ListClients::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - // Depending on your validation, this may fail or create a record - $this->assertDatabaseHas('contacts', ['name' => '12345', 'email' => 'not-an-email']); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_contacts_with_large_file(): void + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void { /* Arrange */ - $rows = []; - for ($i = 0; $i < 1000; $i++) { - $rows[] = "User{$i},user{$i}@example.com"; - } - $csv = "name,email\n" . implode("\n", $rows); - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->test(ListClients::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('contacts', 1000); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_contacts_with_extra_columns(): void + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void { /* Arrange */ - $csv = "name,email,extra\nExtra User,extra@example.com,something\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('contacts', ['name' => 'Extra User', 'email' => 'extra@example.com']); - } - - #[Test] - #[Group('import')] - public function import_contacts_with_missing_required_columns(): void - { - /* Arrange */ - $csv = "name\nMissing Email\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('contacts.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->test(ListClients::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - // Should not create a record if email is required - $this->assertDatabaseCount('contacts', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Clients/Feature/Modules/RelationsExportImportTest.php b/Modules/Clients/Feature/Modules/RelationsExportImportTest.php new file mode 100644 index 000000000..36392a529 --- /dev/null +++ b/Modules/Clients/Feature/Modules/RelationsExportImportTest.php @@ -0,0 +1,177 @@ +for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v2(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $relations = Relation::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_no_records(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + // No relations created + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_special_characters(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $relation = Relation::factory()->for($this->company)->create([ + 'name' => 'ÜRelation, "Test"', + ]); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $relations = Relation::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $relations = Relation::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } +} diff --git a/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php b/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php index 54823a511..85e325ca6 100644 --- a/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php +++ b/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php @@ -2,9 +2,16 @@ namespace Modules\Clients\Filament\Company\Resources\Contacts\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Clients\Filament\Company\Resources\Contacts\ContactResource; +use Modules\Clients\Filament\Exporters\ContactExporter; +use Modules\Clients\Filament\Exporters\ContactLegacyExporter; +use Modules\Clients\Services\ContactService; class ListContacts extends ListRecords { @@ -18,9 +25,34 @@ protected function getHeaderActions(): array return $data; }) ->action(function (array $data) { - app(\Modules\Clients\Services\ContactService::class)->createContact($data); + app(ContactService::class)->createContact($data); }) ->modalWidth('full'), + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(ContactExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(ContactLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(ContactExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(ContactLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php index 5053f9d78..056cda62b 100644 --- a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php +++ b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php @@ -2,9 +2,16 @@ namespace Modules\Clients\Filament\Company\Resources\Relations\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Clients\Filament\Company\Resources\Relations\RelationResource; +use Modules\Clients\Filament\Exporters\RelationExporter; +use Modules\Clients\Filament\Exporters\RelationLegacyExporter; +use Modules\Clients\Services\RelationService; class ListRelations extends ListRecords { @@ -18,9 +25,35 @@ protected function getHeaderActions(): array return $data; }) ->action(function (array $data) { - app(\Modules\Clients\Services\RelationService::class)->createRelation($data); + app(RelationService::class)->createRelation($data); }) ->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(RelationExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(RelationLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(RelationExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(RelationLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Clients/Filament/Exporters/ContactExporter.php b/Modules/Clients/Filament/Exporters/ContactExporter.php new file mode 100644 index 000000000..176a70275 --- /dev/null +++ b/Modules/Clients/Filament/Exporters/ContactExporter.php @@ -0,0 +1,39 @@ +label(trans('ip.relation_id')) + ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''), + ExportColumn::make('type') + ->label(trans('ip.type')) + ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->relation_type?->label() ?? ''), + ExportColumn::make('full_name') + ->label(trans('ip.contact_name')) + ->formatStateUsing(fn ($state, Contact $record) => $record->full_name), + ExportColumn::make('email') + ->label(trans('ip.email')), + ExportColumn::make('phone') + ->label(trans('ip.phone')), + ExportColumn::make('gender') + ->label(trans('ip.gender')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.contact'); + } +} diff --git a/Modules/Clients/Filament/Exporters/ContactLegacyExporter.php b/Modules/Clients/Filament/Exporters/ContactLegacyExporter.php new file mode 100644 index 000000000..0b8de219d --- /dev/null +++ b/Modules/Clients/Filament/Exporters/ContactLegacyExporter.php @@ -0,0 +1,39 @@ +label(trans('ip.relation_id')) + ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''), + ExportColumn::make('type') + ->label(trans('ip.type')) + ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->relation_type?->label() ?? ''), + ExportColumn::make('full_name') + ->label(trans('ip.contact_name')) + ->formatStateUsing(fn ($state, Contact $record) => $record->full_name), + ExportColumn::make('email') + ->label(trans('ip.email')), + ExportColumn::make('phone') + ->label(trans('ip.phone')), + ExportColumn::make('gender') + ->label(trans('ip.gender')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.contact'); + } +} diff --git a/Modules/Clients/Filament/Exporters/RelationExporter.php b/Modules/Clients/Filament/Exporters/RelationExporter.php new file mode 100644 index 000000000..1e8221c00 --- /dev/null +++ b/Modules/Clients/Filament/Exporters/RelationExporter.php @@ -0,0 +1,47 @@ +label(trans('ip.primary_contact')), + ExportColumn::make('relation_type') + ->label(trans('ip.relation_type')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('relation_status') + ->label(trans('ip.relation_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('relation_number') + ->label(trans('ip.relation_number')), + ExportColumn::make('company_name') + ->label(trans('ip.company_name')), + ExportColumn::make('unique_name') + ->label(trans('ip.unique_name')), + ExportColumn::make('coc_number') + ->label(trans('ip.coc_number')), + ExportColumn::make('vat_number') + ->label(trans('ip.vat_number')), + ExportColumn::make('language') + ->label(trans('ip.language')), + ExportColumn::make('email') + ->label(trans('ip.email')), + ExportColumn::make('phone') + ->label(trans('ip.phone')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.relation'); + } +} diff --git a/Modules/Clients/Filament/Exporters/RelationLegacyExporter.php b/Modules/Clients/Filament/Exporters/RelationLegacyExporter.php new file mode 100644 index 000000000..e810680e3 --- /dev/null +++ b/Modules/Clients/Filament/Exporters/RelationLegacyExporter.php @@ -0,0 +1,33 @@ +label(trans('ip.relation_type')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('trading_name') + ->label(trans('ip.trading_name')) + ->formatStateUsing(fn ($state, Relation $record) => $record->trading_name ?? $record->company_name), + ExportColumn::make('email') + ->label(trans('ip.email')), + ExportColumn::make('phone') + ->label(trans('ip.phone')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.relation'); + } +} diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index 55945713c..a0ea58057 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -172,6 +172,10 @@ public function getCustomerEmailAttribute() return $this->email; } + /*public function getPrimaryContactAttribute(): string + { + return mb_trim($this->primary_ontact?->first_name . ' ' . $this->primary_contact?->last_name); + }*/ /* |-------------------------------------------------------------------------- | Scopes diff --git a/Modules/Clients/Services/ContactExportService.php b/Modules/Clients/Services/ContactExportService.php new file mode 100644 index 000000000..24bfcf824 --- /dev/null +++ b/Modules/Clients/Services/ContactExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'contacts-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? ContactsLegacyExport::class : ContactsExport::class; + + return Excel::download(new $exportClass($contacts), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $contacts = Contact::query()->where('company_id', $companyId)->get(); + $fileName = 'contacts-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? ContactsLegacyExport::class : ContactsExport::class; + + return Excel::download(new $exportClass($contacts), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Clients/Services/RelationExportService.php b/Modules/Clients/Services/RelationExportService.php new file mode 100644 index 000000000..812b4e11d --- /dev/null +++ b/Modules/Clients/Services/RelationExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'relations-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? RelationsLegacyExport::class : RelationsExport::class; + + return Excel::download(new $exportClass($relations), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $relations = Relation::query()->where('company_id', $companyId)->get(); + $fileName = 'relations-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? RelationsLegacyExport::class : RelationsExport::class; + + return Excel::download(new $exportClass($relations), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Core/Filament/Exporters/BaseExporter.php b/Modules/Core/Filament/Exporters/BaseExporter.php new file mode 100644 index 000000000..3a56eb1b4 --- /dev/null +++ b/Modules/Core/Filament/Exporters/BaseExporter.php @@ -0,0 +1,31 @@ + $entityName, + 'count' => number_format($export->successful_rows), + 'rows' => trans_choice('ip.row', $export->successful_rows), + ]); + + if ($failedRowsCount = $export->getFailedRowsCount()) { + $body .= ' ' . trans('ip.export_failed_rows', [ + 'count' => number_format($failedRowsCount), + 'rows' => trans_choice('ip.row', $failedRowsCount), + ]); + } + + return $body; + } + + abstract protected static function getEntityName(): string; +} diff --git a/Modules/Core/Filament/Exporters/README.md b/Modules/Core/Filament/Exporters/README.md new file mode 100644 index 000000000..16356bd24 --- /dev/null +++ b/Modules/Core/Filament/Exporters/README.md @@ -0,0 +1,151 @@ +# Export Architecture + +## Overview + +This application uses Filament's export system, which handles exports **asynchronously via queued jobs**. + +**⚠️ Queue Worker Required**: Export functionality requires a running queue worker to process export jobs. + +## Queue Configuration + +### Local Development + +For local development, you can use the `sync` queue driver: + +```bash +# In .env +QUEUE_CONNECTION=sync +``` + +Or run a queue worker in a separate terminal: + +```bash +php artisan queue:work +``` + +### Production + +For production environments, configure a proper queue driver: + +**Redis (Recommended):** +```bash +# In .env +QUEUE_CONNECTION=redis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 +``` + +**Database:** +```bash +# In .env +QUEUE_CONNECTION=database + +# Run migration +php artisan queue:table +php artisan migrate +``` + +**Supervisor Configuration:** + +Use Supervisor to keep queue workers running: + +```ini +[program:invoiceplane-worker] +process_name=%(program_name)s_%(process_num)02d +command=php /path/to/artisan queue:work --sleep=3 --tries=3 --max-time=3600 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=2 +redirect_stderr=true +stdout_logfile=/path/to/storage/logs/worker.log +stopwaitsecs=3600 +``` + +## Database Storage + +**Important**: The `exports` table is managed by Filament and is used **only for internal job coordination**. Export records are temporary and serve these purposes: + +1. **Job Coordination**: Track export progress across multiple queue jobs +2. **File Management**: Store temporary file paths until download +3. **Notification**: Send completion notifications to users + +**The exports table is NOT meant for long-term storage or export history.** + +## Export Lifecycle + +1. User initiates export → Export record created +2. Jobs dispatched to queue → Export record tracks progress +3. File generated → Export record stores file path +4. User downloads file → Export record remains temporarily +5. **Automatic Cleanup**: Filament's Export model uses the `Prunable` trait and will be automatically deleted by Laravel's model pruning system + +## Testing + +Tests use `Queue::fake()` and `Storage::fake()` to avoid actual database/file operations: + +```php +Queue::fake(); +Storage::fake('local'); + +// Act +Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportCsvV2', data: [...]); + +// Assert - verify job dispatching, not database records +Bus::assertChained([...]); +``` + +## Configuration + +### Queue Worker + +Exports will not process without a queue worker running. Choose one of these options: + +**Option 1: Sync Driver (Local Development Only)** +```bash +# In .env +QUEUE_CONNECTION=sync +``` +This processes jobs immediately but blocks the request. + +**Option 2: Queue Worker (Recommended)** +```bash +# Run in separate terminal +php artisan queue:work + +# Or with specific options +php artisan queue:work --queue=default --sleep=3 --tries=3 +``` + +**Option 3: Supervisor (Production)** + +See configuration example above. + +### Model Pruning + +To automatically clean up old export records, run Laravel's model pruning command: + +```bash +php artisan model:prune +``` + +This should be scheduled to run daily in production (add to your task scheduler): + +```php +// In routes/console.php or bootstrap/app.php +Schedule::command('model:prune')->daily(); +``` + +## No Export History + +By design, there is **no export history feature**. Users can export data when needed, download it immediately, and the system automatically cleans up the temporary records. This approach: + +- ✅ Reduces database bloat +- ✅ Improves privacy (no lingering export data) +- ✅ Simplifies the system +- ✅ Follows the principle: "I don't need to see what I exported in the past" diff --git a/Modules/Expenses/Exports/ExpensesExport.php b/Modules/Expenses/Exports/ExpensesExport.php new file mode 100644 index 000000000..e027e0dd2 --- /dev/null +++ b/Modules/Expenses/Exports/ExpensesExport.php @@ -0,0 +1,49 @@ +expenses = $expenses; + } + + public function collection(): Collection + { + return $this->expenses; + } + + public function headings(): array + { + return [ + trans('ip.expense_status'), + trans('ip.expense_category'), + trans('ip.expense_type'), + trans('ip.expense_number'), + trans('ip.vendor'), + trans('ip.expensed_at'), + trans('ip.expense_amount'), + ]; + } + + public function map($row): array + { + return [ + $row->expense_status?->label() ?? '', + $row->expenseCategory?->category_name, + $row->expense_type?->label() ?? '', + $row->expense_number, + $row->vendor?->company_name ?? '', + $row->expensed_at, + $row->expense_amount, + ]; + } +} diff --git a/Modules/Expenses/Exports/ExpensesLegacyExport.php b/Modules/Expenses/Exports/ExpensesLegacyExport.php new file mode 100644 index 000000000..4e848ca67 --- /dev/null +++ b/Modules/Expenses/Exports/ExpensesLegacyExport.php @@ -0,0 +1,41 @@ +expenses = $expenses; + } + + public function collection(): Collection + { + return $this->expenses; + } + + public function headings(): array + { + return [ + trans('ip.expense_category'), + trans('ip.expensed_at'), + trans('ip.amount'), + ]; + } + + public function map($row): array + { + return [ + $row->expenseCategory?->category_name, + $row->expensed_at, + $row->expense_amount, + ]; + } +} diff --git a/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php b/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php new file mode 100644 index 000000000..9e450a325 --- /dev/null +++ b/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php @@ -0,0 +1,239 @@ +for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'expense_status' => ['isEnabled' => true, 'label' => 'Status'], + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v2(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $expenses = Expense::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_status' => ['isEnabled' => true, 'label' => 'Status'], + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_no_records(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + // No expenses created + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_special_characters(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $expense = Expense::factory()->for($this->company)->create([ + 'description' => 'Üxpense, "Test"', + 'amount' => 123.45, + ]); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v2_with_column_selection(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $expenses = Expense::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'expense_status' => ['isEnabled' => true, 'label' => 'Status'], + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => false, 'label' => 'Amount'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $expenses = Expense::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v2_with_data(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $expenses = Expense::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $expenses = Expense::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } +} diff --git a/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php b/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php index b6d30532b..e5eed00ee 100644 --- a/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php +++ b/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php @@ -2,9 +2,15 @@ namespace Modules\Expenses\Filament\Company\Resources\Expenses\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Expenses\Filament\Company\Resources\Expenses\ExpenseResource; +use Modules\Expenses\Filament\Exporters\ExpenseExporter; +use Modules\Expenses\Filament\Exporters\ExpenseLegacyExporter; class ListExpenses extends ListRecords { @@ -21,6 +27,32 @@ protected function getHeaderActions(): array app(\Modules\Expenses\Services\ExpenseService::class)->createExpense($data); }) ->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(ExpenseExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(ExpenseLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(ExpenseExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(ExpenseLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Expenses/Filament/Exporters/ExpenseExporter.php b/Modules/Expenses/Filament/Exporters/ExpenseExporter.php new file mode 100644 index 000000000..ea95bbf15 --- /dev/null +++ b/Modules/Expenses/Filament/Exporters/ExpenseExporter.php @@ -0,0 +1,42 @@ +label(trans('ip.expense_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('expense_category') + ->label(trans('ip.expense_category')) + ->formatStateUsing(fn ($state, Expense $record) => $record->expenseCategory?->category_name ?? ''), + ExportColumn::make('expense_type') + ->label(trans('ip.expense_type')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('expense_number') + ->label(trans('ip.expense_number')), + ExportColumn::make('vendor') + ->label(trans('ip.vendor')) + ->formatStateUsing(fn ($state, Expense $record) => $record->vendor?->company_name ?? ''), + ExportColumn::make('expensed_at') + ->label(trans('ip.expensed_at')) + ->date(), + ExportColumn::make('expense_amount') + ->label(trans('ip.expense_amount')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.expense'); + } +} diff --git a/Modules/Expenses/Filament/Exporters/ExpenseLegacyExporter.php b/Modules/Expenses/Filament/Exporters/ExpenseLegacyExporter.php new file mode 100644 index 000000000..c94f9bdbf --- /dev/null +++ b/Modules/Expenses/Filament/Exporters/ExpenseLegacyExporter.php @@ -0,0 +1,31 @@ +label(trans('ip.expense_category')) + ->formatStateUsing(fn ($state, Expense $record) => $record->expenseCategory?->category_name ?? ''), + ExportColumn::make('expensed_at') + ->label(trans('ip.expensed_at')) + ->date(), + ExportColumn::make('expense_amount') + ->label(trans('ip.amount')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.expense'); + } +} diff --git a/Modules/Expenses/Services/ExpenseExportService.php b/Modules/Expenses/Services/ExpenseExportService.php new file mode 100644 index 000000000..e51cd823f --- /dev/null +++ b/Modules/Expenses/Services/ExpenseExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'expenses-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? ExpensesLegacyExport::class : ExpensesExport::class; + + return Excel::download(new $exportClass($expenses), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $expenses = Expense::query()->where('company_id', $companyId)->get(); + $fileName = 'expenses-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? ExpensesLegacyExport::class : ExpensesExport::class; + + return Excel::download(new $exportClass($expenses), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Invoices/Exports/InvoicesExport.php b/Modules/Invoices/Exports/InvoicesExport.php new file mode 100644 index 000000000..996c6bfef --- /dev/null +++ b/Modules/Invoices/Exports/InvoicesExport.php @@ -0,0 +1,47 @@ +invoices = $invoices; + } + + public function collection(): Collection + { + return $this->invoices; + } + + public function headings(): array + { + return [ + trans('ip.invoice_status'), + trans('ip.invoice_number'), + trans('ip.customer_name'), + trans('ip.invoiced_at'), + trans('ip.invoice_due_at'), + trans('ip.invoice_total'), + ]; + } + + public function map($row): array + { + return [ + $row->invoice_status?->label() ?? '', + $row->invoice_number, + $row->customer?->trading_name ?? $row->customer?->company_name ?? '', + $row->invoiced_at, + $row->invoice_due_at, + $row->invoice_total, + ]; + } +} diff --git a/Modules/Invoices/Exports/InvoicesLegacyExport.php b/Modules/Invoices/Exports/InvoicesLegacyExport.php new file mode 100644 index 000000000..431319d38 --- /dev/null +++ b/Modules/Invoices/Exports/InvoicesLegacyExport.php @@ -0,0 +1,43 @@ +invoices = $invoices; + } + + public function collection(): Collection + { + return $this->invoices; + } + + public function headings(): array + { + return [ + trans('ip.invoice_status'), + trans('ip.invoice_number'), + trans('ip.customer_name'), + trans('ip.invoice_total'), + ]; + } + + public function map($row): array + { + return [ + $row->invoice_status?->label() ?? '', + $row->invoice_number, + $row->customer?->trading_name ?? $row->customer?->company_name ?? '', + $row->invoice_total, + ]; + } +} diff --git a/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php b/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php index f79ecd504..8d7710e52 100644 --- a/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php +++ b/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Invoices\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices; @@ -16,252 +19,214 @@ class InvoicesExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function export_invoices_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsv', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Invoice Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($invoices->count() + 1, $lines); - foreach ($invoices as $invoice) { - $this->assertStringContainsString($invoice->number, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_invoices_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('export', ['format' => 'xlsx']) - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Invoice Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_invoices_with_no_records(): void + public function it_exports_with_no_records(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No invoices created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_invoices_with_special_characters(): void - { - /* Arrange */ - $invoices = Invoice::factory()->for($this->company)->create(['number' => 'INV-Ü, "Test"', 'total' => 123.45]); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListInvoices::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('INV-Ü', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); - } - - #[Test] - #[Group('import')] - public function import_invoices_with_empty_file(): void + public function it_exports_with_special_characters(): void { /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', ''); + Queue::fake(); + Storage::fake('local'); + $invoice = Invoice::factory()->for($this->company)->create([ + 'number' => 'INV-Ü, "Test"', + 'total' => 123.45, + ]); /* Act */ Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('invoices', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_invoices_with_only_headers(): void + #[Group('export')] + public function it_dispatches_csv_export_job_v2(): void { /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', "number,total\n"); + Queue::fake(); + Storage::fake('local'); + $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('invoices', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_invoices_with_invalid_columns(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', "foo,bar\nabc,def\n"); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('invoices', 0); - } - - #[Test] - #[Group('import')] - public function import_invoices_with_duplicate_records(): void + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void { /* Arrange */ - $csv = "number,total\nDup Invoice,100.00\nDup Invoice,100.00\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('invoices', 2); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_invoices_with_invalid_data_types(): void - { - /* Arrange */ - $csv = "number,total\nINV-12345,not-a-number\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('invoices', ['number' => 'INV-12345', 'total' => 'not-a-number']); - } - - #[Test] - #[Group('import')] - public function import_invoices_with_large_file(): void + #[Group('export')] + public function it_dispatches_excel_export_job_v2(): void { /* Arrange */ - $rows = []; - for ($i = 0; $i < 1000; $i++) { - $rows[] = "INV-{$i},{$i}.00"; - } - $csv = "number,total\n" . implode("\n", $rows); - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('invoices', 1000); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_invoices_with_extra_columns(): void + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void { /* Arrange */ - $csv = "number,total,extra\nExtra Invoice,123.45,something\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('invoices', ['number' => 'Extra Invoice', 'total' => 123.45]); - } - - #[Test] - #[Group('import')] - public function import_invoices_with_missing_required_columns(): void - { - /* Arrange */ - $csv = "number\nMissing Total\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('invoices.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListInvoices::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('invoices', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php index 3b21f8d5a..cea1c9609 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php @@ -2,9 +2,15 @@ namespace Modules\Invoices\Filament\Company\Resources\Invoices\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; +use Modules\Invoices\Filament\Exporters\InvoiceExporter; +use Modules\Invoices\Filament\Exporters\InvoiceLegacyExporter; use Modules\Invoices\Services\InvoiceService; class ListInvoices extends ListRecords @@ -22,6 +28,31 @@ protected function getHeaderActions(): array ->action(function (array $data) { app(InvoiceService::class)->createInvoice($data); }), + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(InvoiceExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(InvoiceLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(InvoiceExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(InvoiceLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Invoices/Filament/Exporters/InvoiceExporter.php b/Modules/Invoices/Filament/Exporters/InvoiceExporter.php new file mode 100644 index 000000000..62c47e890 --- /dev/null +++ b/Modules/Invoices/Filament/Exporters/InvoiceExporter.php @@ -0,0 +1,39 @@ +label(trans('ip.invoice_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('invoice_number') + ->label(trans('ip.invoice_number')), + ExportColumn::make('customer_name') + ->label(trans('ip.customer_name')) + ->formatStateUsing(fn ($state, Invoice $record) => $record->customer?->trading_name ?? $record->customer?->company_name ?? ''), + ExportColumn::make('invoiced_at') + ->label(trans('ip.invoiced_at')) + ->date(), + ExportColumn::make('invoice_due_at') + ->label(trans('ip.invoice_due_at')) + ->date(), + ExportColumn::make('invoice_total') + ->label(trans('ip.invoice_total')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.invoice'); + } +} diff --git a/Modules/Invoices/Filament/Exporters/InvoiceLegacyExporter.php b/Modules/Invoices/Filament/Exporters/InvoiceLegacyExporter.php new file mode 100644 index 000000000..429979962 --- /dev/null +++ b/Modules/Invoices/Filament/Exporters/InvoiceLegacyExporter.php @@ -0,0 +1,33 @@ +label(trans('ip.invoice_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('invoice_number') + ->label(trans('ip.invoice_number')), + ExportColumn::make('customer_name') + ->label(trans('ip.customer_name')) + ->formatStateUsing(fn ($state, Invoice $record) => $record->customer?->trading_name ?? $record->customer?->company_name ?? ''), + ExportColumn::make('invoice_total') + ->label(trans('ip.invoice_total')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.invoice'); + } +} diff --git a/Modules/Invoices/Services/InvoiceExportService.php b/Modules/Invoices/Services/InvoiceExportService.php new file mode 100644 index 000000000..199e5b22f --- /dev/null +++ b/Modules/Invoices/Services/InvoiceExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'invoices-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? InvoicesLegacyExport::class : InvoicesExport::class; + + return Excel::download(new $exportClass($invoices), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $invoices = Invoice::query()->where('company_id', $companyId)->get(); + $fileName = 'invoices-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? InvoicesLegacyExport::class : InvoicesExport::class; + + return Excel::download(new $exportClass($invoices), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Payments/Exports/PaymentsExport.php b/Modules/Payments/Exports/PaymentsExport.php new file mode 100644 index 000000000..c8c56c057 --- /dev/null +++ b/Modules/Payments/Exports/PaymentsExport.php @@ -0,0 +1,45 @@ +payments = $payments; + } + + public function collection(): Collection + { + return $this->payments; + } + + public function headings(): array + { + return [ + trans('ip.payment_method'), + trans('ip.payment_status'), + trans('ip.customer_name'), + trans('ip.payment_amount'), + trans('ip.paid_at'), + ]; + } + + public function map($row): array + { + return [ + $row->payment_method?->label() ?? '', + $row->payment_status?->label() ?? '', + $row->customer?->trading_name ?? $row->customer?->company_name ?? '', + $row->payment_amount, + $row->paid_at, + ]; + } +} diff --git a/Modules/Payments/Exports/PaymentsLegacyExport.php b/Modules/Payments/Exports/PaymentsLegacyExport.php new file mode 100644 index 000000000..60ee439d1 --- /dev/null +++ b/Modules/Payments/Exports/PaymentsLegacyExport.php @@ -0,0 +1,43 @@ +payments = $payments; + } + + public function collection(): Collection + { + return $this->payments; + } + + public function headings(): array + { + return [ + trans('ip.payment_method'), + trans('ip.payment_status'), + trans('ip.payment_amount'), + trans('ip.paid_at'), + ]; + } + + public function map($row): array + { + return [ + $row->payment_method?->label() ?? '', + $row->payment_status?->label() ?? '', + $row->payment_amount, + $row->paid_at, + ]; + } +} diff --git a/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php b/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php index 8b13b00fc..f19e61fe6 100644 --- a/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php +++ b/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Payments\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Payments\Filament\Company\Resources\Payments\Pages\ListPayments; @@ -16,252 +19,160 @@ class PaymentsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function export_payments_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($payments->count() + 1, $lines); - foreach ($payments as $payment) { - $this->assertStringContainsString((string) $payment->amount, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_payments_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('export', ['format' => 'xlsx']) - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_payments_with_no_records(): void + public function it_exports_with_no_records(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No payments created - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); - } - - #[Test] - #[Group('export')] - public function export_payments_with_special_characters(): void - { - /* Arrange */ - $payments = Payment::factory()->for($this->company)->create(['amount' => 123.45, 'reference' => 'REF-Ü, "Test"']); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('REF-Ü', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); - } - - #[Test] - #[Group('import')] - public function import_payments_with_empty_file(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', ''); - /* Act */ Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('payments', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_payments_with_only_headers(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', "amount,reference\n"); - - /* Act */ - Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('payments', 0); - } - - #[Test] - #[Group('import')] - public function import_payments_with_invalid_columns(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', "foo,bar\nabc,def\n"); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('payments', 0); - } - - #[Test] - #[Group('import')] - public function import_payments_with_duplicate_records(): void + #[Group('export')] + public function it_exports_with_special_characters(): void { /* Arrange */ - $csv = "amount,reference\n100.00,dup-ref\n100.00,dup-ref\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $payment = Payment::factory()->for($this->company)->create([ + 'amount' => 123.45, + 'note' => 'Ü Payment, "Test"', + ]); /* Act */ Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('payments', 2); - } - - #[Test] - #[Group('import')] - public function import_payments_with_invalid_data_types(): void - { - /* Arrange */ - $csv = "amount,reference\nnot-a-number,ref-123\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertDatabaseHas('payments', ['amount' => 'not-a-number', 'reference' => 'ref-123']); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_payments_with_large_file(): void + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void { /* Arrange */ - $rows = []; - for ($i = 0; $i < 1000; $i++) { - $rows[] = "{$i}.00,ref{$i}"; - } - $csv = "amount,reference\n" . implode("\n", $rows); - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('payments', 1000); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_payments_with_extra_columns(): void + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void { /* Arrange */ - $csv = "amount,reference,extra\n123.45,extra-ref,something\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('payments', ['amount' => 123.45, 'reference' => 'extra-ref']); - } - - #[Test] - #[Group('import')] - public function import_payments_with_missing_required_columns(): void - { - /* Arrange */ - $csv = "amount\nMissing Reference\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('payments.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('payments', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php b/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php index 1afd696cf..b31213a0d 100644 --- a/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php +++ b/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php @@ -2,9 +2,16 @@ namespace Modules\Payments\Filament\Company\Resources\Payments\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Payments\Filament\Company\Resources\Payments\PaymentResource; +use Modules\Payments\Filament\Exporters\PaymentExporter; +use Modules\Payments\Filament\Exporters\PaymentLegacyExporter; +use Modules\Payments\Services\PaymentService; class ListPayments extends ListRecords { @@ -18,9 +25,35 @@ protected function getHeaderActions(): array return $data; }) ->action(function (array $data) { - app(\Modules\Payments\Services\PaymentService::class)->createPayment($data); + app(PaymentService::class)->createPayment($data); }) ->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(PaymentExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(PaymentLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(PaymentExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(PaymentLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php b/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php index 3c3a776b4..4a78e10f4 100644 --- a/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php +++ b/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php @@ -60,7 +60,7 @@ public static function configure(Table $table): Table ->toggleable(), TextColumn::make('payment_method') ->label(trans('ip.payment_method')) - ->formatStateUsing(fn ($state) => trans('ip.' . $state)) + ->formatStateUsing(fn ($state) => $state?->label() ?? '') ->limit(10) ->sortable() ->searchable() diff --git a/Modules/Payments/Filament/Exporters/PaymentExporter.php b/Modules/Payments/Filament/Exporters/PaymentExporter.php new file mode 100644 index 000000000..b60d44d65 --- /dev/null +++ b/Modules/Payments/Filament/Exporters/PaymentExporter.php @@ -0,0 +1,37 @@ +label(trans('ip.payment_method')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('payment_status') + ->label(trans('ip.payment_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('customer_name') + ->label(trans('ip.customer_name')) + ->formatStateUsing(fn ($state, Payment $record) => $record->customer?->trading_name ?? $record->customer?->company_name ?? ''), + ExportColumn::make('payment_amount') + ->label(trans('ip.payment_amount')), + ExportColumn::make('paid_at') + ->label(trans('ip.paid_at')) + ->date(), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.payment'); + } +} diff --git a/Modules/Payments/Filament/Exporters/PaymentLegacyExporter.php b/Modules/Payments/Filament/Exporters/PaymentLegacyExporter.php new file mode 100644 index 000000000..aa022666e --- /dev/null +++ b/Modules/Payments/Filament/Exporters/PaymentLegacyExporter.php @@ -0,0 +1,34 @@ +label(trans('ip.payment_method')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('payment_status') + ->label(trans('ip.payment_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('payment_amount') + ->label(trans('ip.payment_amount')), + ExportColumn::make('paid_at') + ->label(trans('ip.paid_at')) + ->date(), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.payment'); + } +} diff --git a/Modules/Payments/Models/Payment.php b/Modules/Payments/Models/Payment.php index d3cb09489..8c288690c 100644 --- a/Modules/Payments/Models/Payment.php +++ b/Modules/Payments/Models/Payment.php @@ -15,6 +15,7 @@ use Modules\Core\Traits\BelongsToCompany; use Modules\Invoices\Models\Invoice; use Modules\Payments\Database\Factories\PaymentFactory; +use Modules\Payments\Enums\PaymentMethod; use Modules\Payments\Enums\PaymentStatus; /** @@ -42,16 +43,9 @@ class Payment extends Model protected $guarded = []; protected $casts = [ - 'payment_status' => PaymentStatus::class, - 'paid_at' => 'date', - 'payment_amount' => 'float', - 'refunded_amount' => 'float', - 'exchange_rate' => 'float', - 'payment_gateway_fee' => 'float', - 'payment_gateway_percentage' => 'float', - 'is_online' => 'boolean', - 'is_manual' => 'boolean', - 'is_refunded' => 'boolean', + 'payment_method' => PaymentMethod::class, + 'payment_status' => PaymentStatus::class, + 'paid_at' => 'date', ]; /* diff --git a/Modules/Payments/Services/PaymentExportService.php b/Modules/Payments/Services/PaymentExportService.php new file mode 100644 index 000000000..baeb11bd6 --- /dev/null +++ b/Modules/Payments/Services/PaymentExportService.php @@ -0,0 +1,43 @@ +where('company_id', $companyId) + ->orderBy('paid_at', 'desc') + ->limit(10000) + ->get(); + $fileName = 'payments-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? PaymentsLegacyExport::class : PaymentsExport::class; + + return Excel::download(new $exportClass($payments), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $payments = Payment::query()->where('company_id', $companyId)->get(); + $fileName = 'payments-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? PaymentsLegacyExport::class : PaymentsExport::class; + + return Excel::download(new $exportClass($payments), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Products/Exports/ProductsExport.php b/Modules/Products/Exports/ProductsExport.php new file mode 100644 index 000000000..0d732d339 --- /dev/null +++ b/Modules/Products/Exports/ProductsExport.php @@ -0,0 +1,49 @@ +products = $products; + } + + public function collection(): Collection + { + return $this->products; + } + + public function headings(): array + { + return [ + trans('ip.category_name'), + trans('ip.product_unit'), + trans('ip.product_sku'), + trans('ip.product_name'), + trans('ip.product_type'), + trans('ip.product_price'), + trans('ip.cost_price'), + ]; + } + + public function map($row): array + { + return [ + $row->productCategory?->category_name, + $row->productUnit?->unit_name, + $row->code, + $row->product_name, + $row->type?->label() ?? '', + $row->price, + $row->cost_price, + ]; + } +} diff --git a/Modules/Products/Exports/ProductsLegacyExport.php b/Modules/Products/Exports/ProductsLegacyExport.php new file mode 100644 index 000000000..12b6e29f5 --- /dev/null +++ b/Modules/Products/Exports/ProductsLegacyExport.php @@ -0,0 +1,41 @@ +products = $products; + } + + public function collection(): Collection + { + return $this->products; + } + + public function headings(): array + { + return [ + trans('ip.product_sku'), + trans('ip.product_name'), + trans('ip.product_price'), + ]; + } + + public function map($row): array + { + return [ + $row->code, + $row->product_name, + $row->price, + ]; + } +} diff --git a/Modules/Products/Feature/Modules/ProductsExportImportTest.php b/Modules/Products/Feature/Modules/ProductsExportImportTest.php index ec2922dab..e0c08178c 100644 --- a/Modules/Products/Feature/Modules/ProductsExportImportTest.php +++ b/Modules/Products/Feature/Modules/ProductsExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Products\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Products\Filament\Company\Resources\Products\Pages\ListProducts; @@ -16,252 +19,161 @@ class ProductsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function export_products_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); + Bus::fake(); $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($products->count() + 1, $lines); - foreach ($products as $product) { - $this->assertStringContainsString($product->name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_products_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('export', ['format' => 'xlsx']) - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_products_with_no_records(): void + public function it_exports_with_no_records(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No products created - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProducts::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); - } - - #[Test] - #[Group('export')] - public function export_products_with_special_characters(): void - { - /* Arrange */ - $products = Product::factory()->for($this->company)->create(['name' => 'Prødüct, "Test"', 'sku' => 'special-sku']); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProducts::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('Prødüct', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('special-sku', $content); - } - - #[Test] - #[Group('import')] - public function import_products_with_empty_file(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', ''); - /* Act */ Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('products', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_products_with_only_headers(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', "name,sku\n"); - - /* Act */ - Livewire::actingAs($this->user) - ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('products', 0); - } - - #[Test] - #[Group('import')] - public function import_products_with_invalid_columns(): void - { - /* Arrange */ - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', "foo,bar\nabc,def\n"); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('products', 0); - } - - #[Test] - #[Group('import')] - public function import_products_with_duplicate_records(): void + #[Group('export')] + public function it_exports_with_special_characters(): void { /* Arrange */ - $csv = "name,sku\nDup Product,dup-sku\nDup Product,dup-sku\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $product = Product::factory()->for($this->company)->create([ + 'name' => 'ÜProduct, "Test"', + 'price' => 123.45, + ]); /* Act */ Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('products', 2); - } - - #[Test] - #[Group('import')] - public function import_products_with_invalid_data_types(): void - { - /* Arrange */ - $csv = "name,sku\n12345,not-a-sku\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertDatabaseHas('products', ['name' => '12345', 'sku' => 'not-a-sku']); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_products_with_large_file(): void + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void { /* Arrange */ - $rows = []; - for ($i = 0; $i < 1000; $i++) { - $rows[] = "Product{$i},sku{$i}"; - } - $csv = "name,sku\n" . implode("\n", $rows); - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('products', 1000); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_products_with_extra_columns(): void + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void { /* Arrange */ - $csv = "name,sku,extra\nExtra Product,extra-sku,something\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('products', ['name' => 'Extra Product', 'sku' => 'extra-sku']); - } - - #[Test] - #[Group('import')] - public function import_products_with_missing_required_columns(): void - { - /* Arrange */ - $csv = "name\nMissing SKU\n"; - $file = \Illuminate\Http\UploadedFile::fake()->createWithContent('products.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProducts::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('products', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php b/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php index 756488e8d..c5973e0c3 100644 --- a/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php +++ b/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php @@ -2,9 +2,15 @@ namespace Modules\Products\Filament\Company\Resources\Products\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Products\Filament\Company\Resources\Products\ProductResource; +use Modules\Products\Filament\Exporters\ProductExporter; +use Modules\Products\Filament\Exporters\ProductLegacyExporter; use Modules\Products\Services\ProductService; class ListProducts extends ListRecords @@ -21,6 +27,32 @@ protected function getHeaderActions(): array ->action(function (array $data) { app(ProductService::class)->createProduct($data); })->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(ProductExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(ProductLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(ProductExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(ProductLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Products/Filament/Exporters/ProductExporter.php b/Modules/Products/Filament/Exporters/ProductExporter.php new file mode 100644 index 000000000..475809b10 --- /dev/null +++ b/Modules/Products/Filament/Exporters/ProductExporter.php @@ -0,0 +1,40 @@ +label(trans('ip.category_name')) + ->formatStateUsing(fn ($state, Product $record) => $record->productCategory?->category_name ?? ''), + ExportColumn::make('product_unit') + ->label(trans('ip.product_unit')) + ->formatStateUsing(fn ($state, Product $record) => $record->productUnit?->unit_name ?? ''), + ExportColumn::make('code') + ->label(trans('ip.product_sku')), + ExportColumn::make('product_name') + ->label(trans('ip.product_name')), + ExportColumn::make('type') + ->label(trans('ip.product_type')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('price') + ->label(trans('ip.product_price')), + ExportColumn::make('cost_price') + ->label(trans('ip.cost_price')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.product'); + } +} diff --git a/Modules/Products/Filament/Exporters/ProductLegacyExporter.php b/Modules/Products/Filament/Exporters/ProductLegacyExporter.php new file mode 100644 index 000000000..bdb5263d6 --- /dev/null +++ b/Modules/Products/Filament/Exporters/ProductLegacyExporter.php @@ -0,0 +1,29 @@ +label(trans('ip.product_sku')), + ExportColumn::make('product_name') + ->label(trans('ip.product_name')), + ExportColumn::make('price') + ->label(trans('ip.product_price')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.product'); + } +} diff --git a/Modules/Products/Services/ProductExportService.php b/Modules/Products/Services/ProductExportService.php new file mode 100644 index 000000000..76b9df9d8 --- /dev/null +++ b/Modules/Products/Services/ProductExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'products-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? ProductsLegacyExport::class : ProductsExport::class; + + return Excel::download(new $exportClass($products), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $products = Product::query()->where('company_id', $companyId)->get(); + $fileName = 'products-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? ProductsLegacyExport::class : ProductsExport::class; + + return Excel::download(new $exportClass($products), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Projects/Exports/ProjectsExport.php b/Modules/Projects/Exports/ProjectsExport.php new file mode 100644 index 000000000..8c0fc57a9 --- /dev/null +++ b/Modules/Projects/Exports/ProjectsExport.php @@ -0,0 +1,45 @@ +projects = $projects; + } + + public function collection(): Collection + { + return $this->projects; + } + + public function headings(): array + { + return [ + trans('ip.project_name'), + trans('ip.client'), + trans('ip.project_status'), + trans('ip.start_at'), + trans('ip.end_at'), + ]; + } + + public function map($row): array + { + return [ + $row->project_name, + $row->relation?->trading_name ?? $row->relation?->company_name ?? '', + $row->project_status->label() ?? '', + $row->start_at, + $row->end_at, + ]; + } +} diff --git a/Modules/Projects/Exports/ProjectsLegacyExport.php b/Modules/Projects/Exports/ProjectsLegacyExport.php new file mode 100644 index 000000000..16e318760 --- /dev/null +++ b/Modules/Projects/Exports/ProjectsLegacyExport.php @@ -0,0 +1,45 @@ +projects = $projects; + } + + public function collection(): Collection + { + return $this->projects; + } + + public function headings(): array + { + return [ + trans('ip.project_name'), + trans('ip.client'), + trans('ip.project_status'), + trans('ip.start_at'), + trans('ip.end_at'), + ]; + } + + public function map($row): array + { + return [ + $row->project_name, + $row->relation?->trading_name ?? $row->relation?->company_name ?? '', + $row->project_status?->label() ?? '', + $row->start_at, + $row->end_at, + ]; + } +} diff --git a/Modules/Projects/Exports/TasksExport.php b/Modules/Projects/Exports/TasksExport.php new file mode 100644 index 000000000..d0c287751 --- /dev/null +++ b/Modules/Projects/Exports/TasksExport.php @@ -0,0 +1,47 @@ +tasks = $tasks; + } + + public function collection(): Collection + { + return $this->tasks; + } + + public function headings(): array + { + return [ + trans('ip.task_status'), + trans('ip.task_name'), + trans('ip.task_finish_date'), + trans('ip.task_price'), + trans('ip.project_name'), + trans('ip.customer_name'), + ]; + } + + public function map($row): array + { + return [ + $row->task_status?->label() ?? '', + $row->task_name, + $row->due_at, + $row->task_price, + $row->project?->project_name ?? '', + $row->relation?->trading_name ?? $row->relation?->company_name ?? '', + ]; + } +} diff --git a/Modules/Projects/Exports/TasksLegacyExport.php b/Modules/Projects/Exports/TasksLegacyExport.php new file mode 100644 index 000000000..ea5d6e467 --- /dev/null +++ b/Modules/Projects/Exports/TasksLegacyExport.php @@ -0,0 +1,47 @@ +tasks = $tasks; + } + + public function collection(): Collection + { + return $this->tasks; + } + + public function headings(): array + { + return [ + trans('ip.task_status'), + trans('ip.task_name'), + trans('ip.task_finish_date'), + trans('ip.task_price'), + trans('ip.project_name'), + trans('ip.customer_name'), + ]; + } + + public function map($row): array + { + return [ + $row->task_status?->label() ?? '', + $row->task_name, + $row->due_at, + $row->task_price, + $row->project?->project_name ?? '', + $row->relation?->trading_name ?? $row->relation?->company_name ?? '', + ]; + } +} diff --git a/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php b/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php new file mode 100644 index 000000000..2893f7871 --- /dev/null +++ b/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php @@ -0,0 +1,178 @@ +for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProjects::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v2(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $projects = Project::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProjects::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_no_records(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + // No projects created + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProjects::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_special_characters(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $project = Project::factory()->for($this->company)->create([ + 'project_name' => 'ÜProject, "Test"', + 'description' => 'Special chars', + ]); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProjects::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $projects = Project::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProjects::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $projects = Project::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProjects::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } +} diff --git a/Modules/Projects/Feature/Modules/TasksExportImportTest.php b/Modules/Projects/Feature/Modules/TasksExportImportTest.php new file mode 100644 index 000000000..660bd8f81 --- /dev/null +++ b/Modules/Projects/Feature/Modules/TasksExportImportTest.php @@ -0,0 +1,178 @@ +for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListTasks::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v2(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $tasks = Task::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListTasks::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_no_records(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + // No tasks created + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListTasks::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_special_characters(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $task = Task::factory()->for($this->company)->create([ + 'task_name' => 'ÜTask, "Test"', + 'description' => 'Special chars', + ]); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListTasks::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $tasks = Task::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListTasks::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $tasks = Task::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListTasks::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } +} diff --git a/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php b/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php index 05b5562f1..cb1dc18ea 100644 --- a/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php +++ b/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php @@ -2,9 +2,15 @@ namespace Modules\Projects\Filament\Company\Resources\Projects\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Projects\Filament\Company\Resources\Projects\ProjectResource; +use Modules\Projects\Filament\Exporters\ProjectExporter; +use Modules\Projects\Filament\Exporters\ProjectLegacyExporter; class ListProjects extends ListRecords { @@ -21,6 +27,32 @@ protected function getHeaderActions(): array app(\Modules\Projects\Services\ProjectService::class)->createProject($data); }) ->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(ProjectExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(ProjectLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(ProjectExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(ProjectLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php b/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php index c00e87722..491879f6f 100644 --- a/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php +++ b/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php @@ -2,11 +2,17 @@ namespace Modules\Projects\Filament\Company\Resources\Tasks\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Modules\Projects\Filament\Company\Resources\Tasks\TaskResource; +use Modules\Projects\Filament\Exporters\TaskExporter; +use Modules\Projects\Filament\Exporters\TaskLegacyExporter; use Modules\Projects\Models\Task; use Modules\Projects\Services\TaskService; @@ -24,6 +30,32 @@ protected function getHeaderActions(): array ->action(function (array $data) { app(TaskService::class)->createTask($data); })->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(TaskExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(TaskLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(TaskExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(TaskLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php b/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php index 0fa644022..f6c37e3e9 100644 --- a/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php +++ b/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php @@ -36,6 +36,7 @@ public static function configure(Table $table): Table return $status?->color() ?? 'secondary'; }) ->sortable(false), + TextColumn::make('task_name') ->limit(30) ->label(trans('ip.task_name')) diff --git a/Modules/Projects/Filament/Exporters/ProjectExporter.php b/Modules/Projects/Filament/Exporters/ProjectExporter.php new file mode 100644 index 000000000..e9827cf1b --- /dev/null +++ b/Modules/Projects/Filament/Exporters/ProjectExporter.php @@ -0,0 +1,37 @@ +label(trans('ip.project_name')), + ExportColumn::make('client') + ->label(trans('ip.client')) + ->formatStateUsing(fn ($state, Project $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''), + ExportColumn::make('project_status') + ->label(trans('ip.project_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('start_at') + ->label(trans('ip.start_at')) + ->date(), + ExportColumn::make('end_at') + ->label(trans('ip.end_at')) + ->date(), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.project'); + } +} diff --git a/Modules/Projects/Filament/Exporters/ProjectLegacyExporter.php b/Modules/Projects/Filament/Exporters/ProjectLegacyExporter.php new file mode 100644 index 000000000..28b694a4f --- /dev/null +++ b/Modules/Projects/Filament/Exporters/ProjectLegacyExporter.php @@ -0,0 +1,37 @@ +label(trans('ip.project_name')), + ExportColumn::make('client') + ->label(trans('ip.client')) + ->formatStateUsing(fn ($state, Project $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''), + ExportColumn::make('project_status') + ->label(trans('ip.project_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('start_at') + ->label(trans('ip.start_at')) + ->date(), + ExportColumn::make('end_at') + ->label(trans('ip.end_at')) + ->date(), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.project'); + } +} diff --git a/Modules/Projects/Filament/Exporters/TaskExporter.php b/Modules/Projects/Filament/Exporters/TaskExporter.php new file mode 100644 index 000000000..3218496ea --- /dev/null +++ b/Modules/Projects/Filament/Exporters/TaskExporter.php @@ -0,0 +1,39 @@ +label(trans('ip.task_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('task_name') + ->label(trans('ip.task_name')), + ExportColumn::make('due_at') + ->label(trans('ip.task_finish_date')) + ->date(), + ExportColumn::make('task_price') + ->label(trans('ip.task_price')), + ExportColumn::make('project_name') + ->label(trans('ip.project_name')) + ->formatStateUsing(fn ($state, Task $record) => $record->project?->project_name ?? ''), + ExportColumn::make('customer_name') + ->label(trans('ip.customer_name')) + ->formatStateUsing(fn ($state, Task $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.task'); + } +} diff --git a/Modules/Projects/Filament/Exporters/TaskLegacyExporter.php b/Modules/Projects/Filament/Exporters/TaskLegacyExporter.php new file mode 100644 index 000000000..899ba1617 --- /dev/null +++ b/Modules/Projects/Filament/Exporters/TaskLegacyExporter.php @@ -0,0 +1,39 @@ +label(trans('ip.task_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('task_name') + ->label(trans('ip.task_name')), + ExportColumn::make('due_at') + ->label(trans('ip.task_finish_date')) + ->date(), + ExportColumn::make('task_price') + ->label(trans('ip.task_price')), + ExportColumn::make('project_name') + ->label(trans('ip.project_name')) + ->formatStateUsing(fn ($state, Task $record) => $record->project?->project_name ?? ''), + ExportColumn::make('customer_name') + ->label(trans('ip.customer_name')) + ->formatStateUsing(fn ($state, Task $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.task'); + } +} diff --git a/Modules/Projects/Services/ProjectExportService.php b/Modules/Projects/Services/ProjectExportService.php new file mode 100644 index 000000000..f35674445 --- /dev/null +++ b/Modules/Projects/Services/ProjectExportService.php @@ -0,0 +1,28 @@ +exportWithVersion($format, config('ip.export_version', 2)); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $projects = Project::query()->where('company_id', $companyId)->get(); + $fileName = 'projects-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? ProjectsLegacyExport::class : ProjectsExport::class; + + return Excel::download(new $exportClass($projects), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Projects/Services/TaskExportService.php b/Modules/Projects/Services/TaskExportService.php new file mode 100644 index 000000000..cb86578d2 --- /dev/null +++ b/Modules/Projects/Services/TaskExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'tasks-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? TasksLegacyExport::class : TasksExport::class; + + return Excel::download(new $exportClass($tasks), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $tasks = Task::query()->where('company_id', $companyId)->get(); + $fileName = 'tasks-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? TasksLegacyExport::class : TasksExport::class; + + return Excel::download(new $exportClass($tasks), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/Modules/Quotes/Exports/QuotesExport.php b/Modules/Quotes/Exports/QuotesExport.php new file mode 100644 index 000000000..64f28d847 --- /dev/null +++ b/Modules/Quotes/Exports/QuotesExport.php @@ -0,0 +1,51 @@ +quotes = $quotes; + } + + public function collection(): Collection + { + return $this->quotes; + } + + public function headings(): array + { + return [ + trans('ip.quote_status'), + trans('ip.quote_number'), + trans('ip.prospect_name'), + trans('ip.quoted_at'), + trans('ip.quote_expires_at'), + trans('ip.quote_item_subtotal'), + trans('ip.quote_tax_total'), + trans('ip.quote_total'), + ]; + } + + public function map($row): array + { + return [ + $row->quote_status?->label() ?? '', + $row->quote_number, + $row->prospect?->trading_name ?? $row->prospect?->company_name ?? '', + $row->quoted_at, + $row->quote_expires_at, + $row->quote_item_subtotal, + $row->quote_tax_total, + $row->quote_total, + ]; + } +} diff --git a/Modules/Quotes/Exports/QuotesLegacyExport.php b/Modules/Quotes/Exports/QuotesLegacyExport.php new file mode 100644 index 000000000..fdae12b68 --- /dev/null +++ b/Modules/Quotes/Exports/QuotesLegacyExport.php @@ -0,0 +1,47 @@ +quotes = $quotes; + } + + public function collection(): Collection + { + return $this->quotes; + } + + public function headings(): array + { + return [ + trans('ip.quote_status'), + trans('ip.quote_number'), + trans('ip.prospect_name'), + trans('ip.quoted_at'), + trans('ip.quote_expires_at'), + trans('ip.quote_total'), + ]; + } + + public function map($row): array + { + return [ + $row->quote_status?->label() ?? '', + $row->quote_number, + $row->prospect?->trading_name ?? $row->prospect?->company_name ?? '', + $row->quoted_at, + $row->quote_expires_at, + $row->quote_total, + ]; + } +} diff --git a/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php b/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php index 4a14d4bc8..f4e61f63d 100644 --- a/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php +++ b/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php @@ -3,7 +3,9 @@ namespace Modules\Quotes\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\ListQuotes; @@ -17,252 +19,110 @@ class QuotesExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function export_quotes_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $quotes = Quote::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Quote Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($quotes->count() + 1, $lines); - foreach ($quotes as $quote) { - $this->assertStringContainsString($quote->number, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_quotes_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); $quotes = Quote::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('export', ['format' => 'xlsx']) - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function export_quotes_with_no_records(): void + public function it_exports_with_no_records(): void { /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No quotes created - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); - } - - #[Test] - #[Group('export')] - public function export_quotes_with_special_characters(): void - { - /* Arrange */ - $quotes = Quote::factory()->for($this->company)->create(['number' => 'QÜØTË, "Test"', 'total' => 123.45]); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('export') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('QÜØTË', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_empty_file(): void - { - /* Arrange */ - $file = UploadedFile::fake()->createWithContent('quotes.csv', ''); - - /* Act */ - Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('quotes', 0); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_only_headers(): void - { - /* Arrange */ - $file = UploadedFile::fake()->createWithContent('quotes.csv', "number,total\n"); - /* Act */ Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('quotes', 0); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_invalid_columns(): void - { - /* Arrange */ - $file = UploadedFile::fake()->createWithContent('quotes.csv', "foo,bar\nabc,def\n"); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('quotes', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] - #[Group('import')] - public function import_quotes_with_duplicate_records(): void - { - /* Arrange */ - $csv = "number,total\nDup Quote,100.00\nDup Quote,100.00\n"; - $file = UploadedFile::fake()->createWithContent('quotes.csv', $csv); - - /* Act */ - Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('quotes', 2); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_invalid_data_types(): void - { - /* Arrange */ - $csv = "number,total\nQ-12345,not-a-number\n"; - $file = UploadedFile::fake()->createWithContent('quotes.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('quotes', ['number' => 'Q-12345', 'total' => 'not-a-number']); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_large_file(): void - { - /* Arrange */ - $rows = []; - for ($i = 0; $i < 1000; $i++) { - $rows[] = "Q-{$i},{$i}.00"; - } - $csv = "number,total\n" . implode("\n", $rows); - $file = UploadedFile::fake()->createWithContent('quotes.csv', $csv); - - /* Act */ - Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseCount('quotes', 1000); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_extra_columns(): void + #[Group('export')] + public function it_exports_with_special_characters(): void { /* Arrange */ - $csv = "number,total,extra\nExtra Quote,123.45,something\n"; - $file = UploadedFile::fake()->createWithContent('quotes.csv', $csv); + Queue::fake(); + Storage::fake('local'); + $quote = Quote::factory()->for($this->company)->create([ + 'number' => 'QÜØTË, "Test"', + 'total' => 123.45, + ]); /* Act */ Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); - - /* Assert */ - $this->assertDatabaseHas('quotes', ['number' => 'Extra Quote', 'total' => 123.45]); - } - - #[Test] - #[Group('import')] - public function import_quotes_with_missing_required_columns(): void - { - /* Arrange */ - $csv = "number\nMissing Total\n"; - $file = UploadedFile::fake()->createWithContent('quotes.csv', $csv); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListQuotes::class) - ->mountAction('import') - ->set('data.file', $file) - ->callMountedAction(); + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertDatabaseCount('quotes', 0); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php index 778bf1711..1be670bfe 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php @@ -2,9 +2,15 @@ namespace Modules\Quotes\Filament\Company\Resources\Quotes\Pages; +use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; +use Filament\Actions\ExportAction; +use Filament\Actions\Exports\Enums\ExportFormat; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Icons\Heroicon; use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; +use Modules\Quotes\Filament\Exporters\QuoteExporter; +use Modules\Quotes\Filament\Exporters\QuoteLegacyExporter; use Modules\Quotes\Services\QuoteService; class ListQuotes extends ListRecords @@ -22,6 +28,32 @@ protected function getHeaderActions(): array app(QuoteService::class)->createQuote($data); }) ->modalWidth('full'), + + ActionGroup::make([ + ExportAction::make('exportCsvV2') + ->label('Export as CSV (v2)') + ->icon('heroicon-o-document-text') + ->exporter(QuoteExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportCsvV1') + ->label('Export as CSV (v1, Legacy)') + ->icon('heroicon-o-document-text') + ->exporter(QuoteLegacyExporter::class) + ->formats([ExportFormat::Csv]), + ExportAction::make('exportExcelV2') + ->label('Export as Excel (v2)') + ->icon('heroicon-o-document') + ->exporter(QuoteExporter::class) + ->formats([ExportFormat::Xlsx]), + ExportAction::make('exportExcelV1') + ->label('Export as Excel (v1, Legacy)') + ->icon('heroicon-o-document') + ->exporter(QuoteLegacyExporter::class) + ->formats([ExportFormat::Xlsx]), + ]) + ->label('Export') + ->icon(Heroicon::OutlinedFolderArrowDown) + ->button(), ]; } } diff --git a/Modules/Quotes/Filament/Exporters/QuoteExporter.php b/Modules/Quotes/Filament/Exporters/QuoteExporter.php new file mode 100644 index 000000000..3f00fcd0e --- /dev/null +++ b/Modules/Quotes/Filament/Exporters/QuoteExporter.php @@ -0,0 +1,43 @@ +label(trans('ip.quote_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('quote_number') + ->label(trans('ip.quote_number')), + ExportColumn::make('prospect_name') + ->label(trans('ip.prospect_name')) + ->formatStateUsing(fn ($state, Quote $record) => $record->prospect?->trading_name ?? $record->prospect?->company_name ?? ''), + ExportColumn::make('quoted_at') + ->label(trans('ip.quoted_at')) + ->date(), + ExportColumn::make('quote_expires_at') + ->label(trans('ip.quote_expires_at')) + ->date(), + ExportColumn::make('quote_item_subtotal') + ->label(trans('ip.quote_item_subtotal')), + ExportColumn::make('quote_tax_total') + ->label(trans('ip.quote_tax_total')), + ExportColumn::make('quote_total') + ->label(trans('ip.quote_total')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.quote'); + } +} diff --git a/Modules/Quotes/Filament/Exporters/QuoteLegacyExporter.php b/Modules/Quotes/Filament/Exporters/QuoteLegacyExporter.php new file mode 100644 index 000000000..69f332167 --- /dev/null +++ b/Modules/Quotes/Filament/Exporters/QuoteLegacyExporter.php @@ -0,0 +1,39 @@ +label(trans('ip.quote_status')) + ->formatStateUsing(fn ($state) => $state?->label() ?? ''), + ExportColumn::make('quote_number') + ->label(trans('ip.quote_number')), + ExportColumn::make('prospect_name') + ->label(trans('ip.prospect_name')) + ->formatStateUsing(fn ($state, Quote $record) => $record->prospect?->trading_name ?? $record->prospect?->company_name ?? ''), + ExportColumn::make('quoted_at') + ->label(trans('ip.quoted_at')) + ->date(), + ExportColumn::make('quote_expires_at') + ->label(trans('ip.quote_expires_at')) + ->date(), + ExportColumn::make('quote_total') + ->label(trans('ip.quote_total')), + ]; + } + + protected static function getEntityName(): string + { + return trans('ip.quote'); + } +} diff --git a/Modules/Quotes/Services/QuoteExportService.php b/Modules/Quotes/Services/QuoteExportService.php new file mode 100644 index 000000000..7a54707df --- /dev/null +++ b/Modules/Quotes/Services/QuoteExportService.php @@ -0,0 +1,34 @@ +where('company_id', $companyId)->get(); + $fileName = 'quotes-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $version = config('ip.export_version', 2); + $exportClass = $version === 1 ? QuotesLegacyExport::class : QuotesExport::class; + + return Excel::download(new $exportClass($quotes), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } + + public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse + { + $companyId = session('current_company_id'); + $quotes = Quote::query()->where('company_id', $companyId)->get(); + $fileName = 'quotes-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx'); + $exportClass = $version === 1 ? QuotesLegacyExport::class : QuotesExport::class; + + return Excel::download(new $exportClass($quotes), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX); + } +} diff --git a/README.md b/README.md index 37fdaa0df..a217d622d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - Modular Architecture (Laravel + Filament) - Multi-Tenant Support via Filament Companies - Realtime UI with Livewire +- Asynchronous Export System (requires queue workers) --- @@ -22,8 +23,13 @@ composer install cp .env.example .env php artisan key:generate php artisan migrate --seed + +# Start queue worker for export functionality +php artisan queue:work ``` +**Note:** Export functionality requires a queue worker to be running. For production, configure a queue driver (Redis, database, etc.) and use a process manager like Supervisor. + For detailed steps, see: INSTALLATION.md diff --git a/composer.json b/composer.json index df7584df3..bf0d4fa0c 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "filament/tables": "^4.0", "filament/widgets": "^4.0", "laravel/framework": "^12.26", + "maatwebsite/excel": "^3.1", "nwidart/laravel-modules": "^12.0", "spatie/laravel-permission": "^6.21" }, diff --git a/composer.lock b/composer.lock index 18df1197c..6681e3857 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e5a50d99bb056f8d371ab08b03492b19", + "content-hash": "1bf72eded6f8d0d3afd9d9eef4c7161e", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -224,25 +224,25 @@ }, { "name": "brick/math", - "version": "0.13.1", + "version": "0.14.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -272,7 +272,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.13.1" + "source": "https://github.com/brick/math/tree/0.14.0" }, "funding": [ { @@ -280,7 +280,7 @@ "type": "github" } ], - "time": "2025-03-29T13:50:30+00:00" + "time": "2025-08-29T12:40:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -510,6 +510,162 @@ ], "time": "2024-07-16T11:13:48+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", @@ -692,16 +848,16 @@ }, { "name": "doctrine/dbal", - "version": "4.3.2", + "version": "4.3.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "7669f131d43b880de168b2d2df9687d152d6c762" + "reference": "231959669bb2173194c95636eae7f1b41b2a8b19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/7669f131d43b880de168b2d2df9687d152d6c762", - "reference": "7669f131d43b880de168b2d2df9687d152d6c762", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/231959669bb2173194c95636eae7f1b41b2a8b19", + "reference": "231959669bb2173194c95636eae7f1b41b2a8b19", "shasum": "" }, "require": { @@ -711,10 +867,10 @@ "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "13.0.0", + "doctrine/coding-standard": "13.0.1", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.2", - "phpstan/phpstan": "2.1.17", + "phpstan/phpstan": "2.1.22", "phpstan/phpstan-phpunit": "2.0.6", "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "11.5.23", @@ -778,7 +934,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.3.2" + "source": "https://github.com/doctrine/dbal/tree/4.3.3" }, "funding": [ { @@ -794,7 +950,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T13:30:38+00:00" + "time": "2025-09-04T23:52:42+00:00" }, { "name": "doctrine/deprecations", @@ -1143,18 +1299,79 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.18.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "cb56001e54359df7ae76dc522d08845dc741621b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b", + "reference": "cb56001e54359df7ae76dc522d08845dc741621b", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0" + }, + "time": "2024-11-01T03:51:45+00:00" + }, { "name": "filament/actions", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "62572b3e8947444ae95fa332cb96782c1ea3ce88" + "reference": "839ce7099627bea6f44fb71d4302e2696795fede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/62572b3e8947444ae95fa332cb96782c1ea3ce88", - "reference": "62572b3e8947444ae95fa332cb96782c1ea3ce88", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/839ce7099627bea6f44fb71d4302e2696795fede", + "reference": "839ce7099627bea6f44fb71d4302e2696795fede", "shasum": "" }, "require": { @@ -1190,20 +1407,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-19T09:14:20+00:00" + "time": "2025-09-04T14:12:54+00:00" }, { "name": "filament/filament", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "e32aba103a7549bf47f9b603865182b18aa4a962" + "reference": "5cc9f39f8f2112776d66cd0daeab8430f77921ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/e32aba103a7549bf47f9b603865182b18aa4a962", - "reference": "e32aba103a7549bf47f9b603865182b18aa4a962", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/5cc9f39f8f2112776d66cd0daeab8430f77921ce", + "reference": "5cc9f39f8f2112776d66cd0daeab8430f77921ce", "shasum": "" }, "require": { @@ -1247,20 +1464,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-19T09:14:22+00:00" + "time": "2025-09-04T14:12:49+00:00" }, { "name": "filament/forms", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "73cf795fbeedebb2aa4413fe68110cf753bcd753" + "reference": "8aea1f3a16bceefd226554953013c457aa430c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/73cf795fbeedebb2aa4413fe68110cf753bcd753", - "reference": "73cf795fbeedebb2aa4413fe68110cf753bcd753", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/8aea1f3a16bceefd226554953013c457aa430c40", + "reference": "8aea1f3a16bceefd226554953013c457aa430c40", "shasum": "" }, "require": { @@ -1297,20 +1514,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-19T09:14:28+00:00" + "time": "2025-09-04T14:12:46+00:00" }, { "name": "filament/infolists", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "18ecdb29af0ba791176ae130448f30932b96319d" + "reference": "ecf47afbcc80732671b7c9170e7d9807a9f5a22b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/18ecdb29af0ba791176ae130448f30932b96319d", - "reference": "18ecdb29af0ba791176ae130448f30932b96319d", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/ecf47afbcc80732671b7c9170e7d9807a9f5a22b", + "reference": "ecf47afbcc80732671b7c9170e7d9807a9f5a22b", "shasum": "" }, "require": { @@ -1342,20 +1559,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-18T22:19:29+00:00" + "time": "2025-09-04T14:12:51+00:00" }, { "name": "filament/notifications", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", - "reference": "7f66df600982cacb8570bd3e8559a706a8151057" + "reference": "378b819305ca262eb8f0677774105dfccce49ac1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/notifications/zipball/7f66df600982cacb8570bd3e8559a706a8151057", - "reference": "7f66df600982cacb8570bd3e8559a706a8151057", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/378b819305ca262eb8f0677774105dfccce49ac1", + "reference": "378b819305ca262eb8f0677774105dfccce49ac1", "shasum": "" }, "require": { @@ -1389,20 +1606,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-15T11:44:04+00:00" + "time": "2025-09-04T14:12:43+00:00" }, { "name": "filament/schemas", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", - "reference": "7ecfa3550ac4aaf7241b61327f6634d68d568767" + "reference": "328a2b34e812a56b33cf6e4184e1f177094b4a47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/schemas/zipball/7ecfa3550ac4aaf7241b61327f6634d68d568767", - "reference": "7ecfa3550ac4aaf7241b61327f6634d68d568767", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/328a2b34e812a56b33cf6e4184e1f177094b4a47", + "reference": "328a2b34e812a56b33cf6e4184e1f177094b4a47", "shasum": "" }, "require": { @@ -1434,20 +1651,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-18T22:19:37+00:00" + "time": "2025-09-04T14:12:50+00:00" }, { "name": "filament/support", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "a381275362f3d5a69989e50d8199495d1f99a90a" + "reference": "48684756dd3609abebb5c659cf90113a26e9e219" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/a381275362f3d5a69989e50d8199495d1f99a90a", - "reference": "a381275362f3d5a69989e50d8199495d1f99a90a", + "url": "https://api.github.com/repos/filamentphp/support/zipball/48684756dd3609abebb5c659cf90113a26e9e219", + "reference": "48684756dd3609abebb5c659cf90113a26e9e219", "shasum": "" }, "require": { @@ -1492,20 +1709,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-19T09:14:22+00:00" + "time": "2025-09-04T14:12:45+00:00" }, { "name": "filament/tables", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "6d9a9b3db2a4d10265cde72c80e235bc63ec6873" + "reference": "0c8e7f4f2bacfe1bea6dcfeb5c3d6cc0d173844f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/6d9a9b3db2a4d10265cde72c80e235bc63ec6873", - "reference": "6d9a9b3db2a4d10265cde72c80e235bc63ec6873", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/0c8e7f4f2bacfe1bea6dcfeb5c3d6cc0d173844f", + "reference": "0c8e7f4f2bacfe1bea6dcfeb5c3d6cc0d173844f", "shasum": "" }, "require": { @@ -1537,20 +1754,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-19T09:14:15+00:00" + "time": "2025-09-04T14:12:44+00:00" }, { "name": "filament/widgets", - "version": "v4.0.3", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "1941f9f3f7f728f64a5d7794a5ff7db64708f5ce" + "reference": "f761a52df367f8bde47d9d2518c3c0b646155345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/1941f9f3f7f728f64a5d7794a5ff7db64708f5ce", - "reference": "1941f9f3f7f728f64a5d7794a5ff7db64708f5ce", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/f761a52df367f8bde47d9d2518c3c0b646155345", + "reference": "f761a52df367f8bde47d9d2518c3c0b646155345", "shasum": "" }, "require": { @@ -1581,7 +1798,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-08-18T22:19:38+00:00" + "time": "2025-09-04T14:12:52+00:00" }, { "name": "fruitcake/php-cors", @@ -2192,20 +2409,20 @@ }, { "name": "laravel/framework", - "version": "v12.26.2", + "version": "v12.28.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "56c5fc46cfb1005d0aaa82c7592d63edb776a787" + "reference": "868c1f2d3dba4df6d21e3a8d818479f094cfd942" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/56c5fc46cfb1005d0aaa82c7592d63edb776a787", - "reference": "56c5fc46cfb1005d0aaa82c7592d63edb776a787", + "url": "https://api.github.com/repos/laravel/framework/zipball/868c1f2d3dba4df6d21e3a8d818479f094cfd942", + "reference": "868c1f2d3dba4df6d21e3a8d818479f094cfd942", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -2241,8 +2458,8 @@ "symfony/http-kernel": "^7.2.0", "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.31", - "symfony/polyfill-php84": "^1.31", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", "symfony/polyfill-php85": "^1.33", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", @@ -2279,6 +2496,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", "illuminate/mail": "self.version", @@ -2311,7 +2529,8 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.6.0", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.6.5", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -2405,7 +2624,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-26T18:04:56+00:00" + "time": "2025-09-04T14:58:12+00:00" }, { "name": "laravel/prompts", @@ -3327,6 +3546,272 @@ ], "time": "2025-07-17T05:12:15+00:00" }, + { + "name": "maatwebsite/excel", + "version": "3.1.67", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.0", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2025-08-26T09:13:16+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-07-17T11:15:13+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", @@ -3499,16 +3984,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.2", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", "shasum": "" }, "require": { @@ -3526,13 +4011,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.75.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", "kylekatarnls/multi-tester": "^2.5.3", "phpmd/phpmd": "^2.15.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", - "phpunit/phpunit": "^10.5.46", - "squizlabs/php_codesniffer": "^3.13.0" + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" }, "bin": [ "bin/carbon" @@ -3600,7 +4085,7 @@ "type": "tidelift" } ], - "time": "2025-08-02T09:36:06+00:00" + "time": "2025-09-06T13:39:36+00:00" }, { "name": "nette/php-generator", @@ -4003,16 +4488,16 @@ }, { "name": "openspout/openspout", - "version": "v4.30.1", + "version": "v4.32.0", "source": { "type": "git", "url": "https://github.com/openspout/openspout.git", - "reference": "4550fc0dbf01aff86d12691f8a7f6ce22d2b2edc" + "reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/openspout/openspout/zipball/4550fc0dbf01aff86d12691f8a7f6ce22d2b2edc", - "reference": "4550fc0dbf01aff86d12691f8a7f6ce22d2b2edc", + "url": "https://api.github.com/repos/openspout/openspout/zipball/41f045c1f632e1474e15d4c7bc3abcb4a153563d", + "reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d", "shasum": "" }, "require": { @@ -4022,17 +4507,17 @@ "ext-libxml": "*", "ext-xmlreader": "*", "ext-zip": "*", - "php": "~8.3.0 || ~8.4.0" + "php": "~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { "ext-zlib": "*", - "friendsofphp/php-cs-fixer": "^3.80.0", - "infection/infection": "^0.30.1", + "friendsofphp/php-cs-fixer": "^3.86.0", + "infection/infection": "^0.31.2", "phpbench/phpbench": "^1.4.1", - "phpstan/phpstan": "^2.1.17", - "phpstan/phpstan-phpunit": "^2.0.6", - "phpstan/phpstan-strict-rules": "^2.0.4", - "phpunit/phpunit": "^12.2.6" + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^12.3.7" }, "suggest": { "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", @@ -4080,7 +4565,7 @@ ], "support": { "issues": "https://github.com/openspout/openspout/issues", - "source": "https://github.com/openspout/openspout/tree/v4.30.1" + "source": "https://github.com/openspout/openspout/tree/v4.32.0" }, "funding": [ { @@ -4092,7 +4577,7 @@ "type": "github" } ], - "time": "2025-07-07T06:15:55+00:00" + "time": "2025-09-03T16:03:54+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -4161,6 +4646,112 @@ }, "time": "2024-05-08T12:36:18+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "2f39286e0136673778b7a142b3f0d141e43d1714" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2f39286e0136673778b7a142b3f0d141e43d1714", + "reference": "2f39286e0136673778b7a142b3f0d141e43d1714", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.0" + }, + "time": "2025-08-10T06:28:02+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -4938,20 +5529,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -5010,9 +5601,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "ryangjchandler/blade-capture-directive", @@ -5514,16 +6105,16 @@ }, { "name": "symfony/console", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" + "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", + "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", "shasum": "" }, "require": { @@ -5588,7 +6179,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.2" + "source": "https://github.com/symfony/console/tree/v7.3.3" }, "funding": [ { @@ -5608,7 +6199,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:13:41+00:00" + "time": "2025-08-25T06:35:40+00:00" }, { "name": "symfony/css-selector", @@ -5825,16 +6416,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", "shasum": "" }, "require": { @@ -5885,7 +6476,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" }, "funding": [ { @@ -5896,12 +6487,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-22T09:11:45+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6049,16 +6644,16 @@ }, { "name": "symfony/html-sanitizer", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "3388e208450fcac57d24aef4d5ae41037b663630" + "reference": "8740fc48979f649dee8b8fc51a2698e5c190bf12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/3388e208450fcac57d24aef4d5ae41037b663630", - "reference": "3388e208450fcac57d24aef4d5ae41037b663630", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/8740fc48979f649dee8b8fc51a2698e5c190bf12", + "reference": "8740fc48979f649dee8b8fc51a2698e5c190bf12", "shasum": "" }, "require": { @@ -6098,7 +6693,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.2" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.3" }, "funding": [ { @@ -6118,20 +6713,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:29:33+00:00" + "time": "2025-08-12T10:34:03+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" + "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00", + "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00", "shasum": "" }, "require": { @@ -6181,7 +6776,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.3" }, "funding": [ { @@ -6201,20 +6796,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-08-20T08:04:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" + "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", - "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b", + "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b", "shasum": "" }, "require": { @@ -6299,7 +6894,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.3" }, "funding": [ { @@ -6319,20 +6914,20 @@ "type": "tidelift" } ], - "time": "2025-07-31T10:45:04+00:00" + "time": "2025-08-29T08:23:45+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" + "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", - "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575", + "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575", "shasum": "" }, "require": { @@ -6383,7 +6978,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.2" + "source": "https://github.com/symfony/mailer/tree/v7.3.3" }, "funding": [ { @@ -6403,7 +6998,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "symfony/mime", @@ -7324,16 +7919,16 @@ }, { "name": "symfony/process", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + "reference": "32241012d521e2e8a9d713adb0812bb773b907f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1", + "reference": "32241012d521e2e8a9d713adb0812bb773b907f1", "shasum": "" }, "require": { @@ -7365,7 +7960,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.0" + "source": "https://github.com/symfony/process/tree/v7.3.3" }, "funding": [ { @@ -7376,12 +7971,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-17T09:11:12+00:00" + "time": "2025-08-18T09:42:54+00:00" }, { "name": "symfony/routing", @@ -7553,16 +8152,16 @@ }, { "name": "symfony/string", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" + "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", + "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", "shasum": "" }, "require": { @@ -7620,7 +8219,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.2" + "source": "https://github.com/symfony/string/tree/v7.3.3" }, "funding": [ { @@ -7640,20 +8239,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-08-25T06:35:40+00:00" }, { "name": "symfony/translation", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" + "reference": "e0837b4cbcef63c754d89a4806575cada743a38d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", - "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d", + "reference": "e0837b4cbcef63c754d89a4806575cada743a38d", "shasum": "" }, "require": { @@ -7720,7 +8319,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.2" + "source": "https://github.com/symfony/translation/tree/v7.3.3" }, "funding": [ { @@ -7740,7 +8339,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:31:46+00:00" + "time": "2025-08-01T21:02:37+00:00" }, { "name": "symfony/translation-contracts", @@ -7896,16 +8495,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "53205bea27450dc5c65377518b3275e126d45e75" + "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", - "reference": "53205bea27450dc5c65377518b3275e126d45e75", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", + "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", "shasum": "" }, "require": { @@ -7959,7 +8558,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.3" }, "funding": [ { @@ -7979,7 +8578,7 @@ "type": "tidelift" } ], - "time": "2025-07-29T20:02:46+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -9030,16 +9629,16 @@ }, { "name": "laravel/boost", - "version": "v1.0.20", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "c2ac67ce42c39ffe6c3c073c9202d54a96eaa5b5" + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/c2ac67ce42c39ffe6c3c073c9202d54a96eaa5b5", - "reference": "c2ac67ce42c39ffe6c3c073c9202d54a96eaa5b5", + "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076", "shasum": "" }, "require": { @@ -9050,7 +9649,7 @@ "illuminate/support": "^10.0|^11.0|^12.0", "laravel/mcp": "^0.1.1", "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2.4", + "laravel/roster": "^0.2.5", "php": "^8.1" }, "require-dev": { @@ -9091,7 +9690,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-28T14:46:17+00:00" + "time": "2025-09-04T12:16:09+00:00" }, { "name": "laravel/mcp", @@ -9307,16 +9906,16 @@ }, { "name": "laravel/roster", - "version": "v0.2.5", + "version": "v0.2.6", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "0252fa419733c61b3ebeba8e4e2b9ad2a63f3a17" + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/0252fa419733c61b3ebeba8e4e2b9ad2a63f3a17", - "reference": "0252fa419733c61b3ebeba8e4e2b9ad2a63f3a17", + "url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514", "shasum": "" }, "require": { @@ -9364,20 +9963,20 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-08-29T07:47:42+00:00" + "time": "2025-09-04T07:31:39+00:00" }, { "name": "laravel/sail", - "version": "v1.44.0", + "version": "v1.45.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe" + "reference": "019a2933ff4a9199f098d4259713f9bc266a874e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", - "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "url": "https://api.github.com/repos/laravel/sail/zipball/019a2933ff4a9199f098d4259713f9bc266a874e", + "reference": "019a2933ff4a9199f098d4259713f9bc266a874e", "shasum": "" }, "require": { @@ -9427,7 +10026,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-07-04T16:17:06+00:00" + "time": "2025-08-25T19:28:31+00:00" }, { "name": "laravel/tinker", @@ -10046,16 +10645,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.10", + "version": "11.0.11", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", - "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", "shasum": "" }, "require": { @@ -10112,7 +10711,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" }, "funding": [ { @@ -10132,7 +10731,7 @@ "type": "tidelift" } ], - "time": "2025-06-18T08:56:18+00:00" + "time": "2025-08-27T14:37:49+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10381,16 +10980,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.34", + "version": "11.5.36", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3e4c6ef395f7cb61a6206c23e0e04b31724174f2" + "reference": "264a87c7ef68b1ab9af7172357740dc266df5957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e4c6ef395f7cb61a6206c23e0e04b31724174f2", - "reference": "3e4c6ef395f7cb61a6206c23e0e04b31724174f2", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/264a87c7ef68b1ab9af7172357740dc266df5957", + "reference": "264a87c7ef68b1ab9af7172357740dc266df5957", "shasum": "" }, "require": { @@ -10404,7 +11003,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.11", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", @@ -10462,7 +11061,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.34" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.36" }, "funding": [ { @@ -10486,7 +11085,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T14:41:45+00:00" + "time": "2025-09-03T06:24:17+00:00" }, { "name": "psy/psysh", @@ -10568,16 +11167,16 @@ }, { "name": "rector/rector", - "version": "2.1.4", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "fe613c528819222f8686a9a037a315ef9d4915b3" + "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/fe613c528819222f8686a9a037a315ef9d4915b3", - "reference": "fe613c528819222f8686a9a037a315ef9d4915b3", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/729aabc0ec66e700ef164e26454a1357f222a2f3", + "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3", "shasum": "" }, "require": { @@ -10616,7 +11215,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.1.4" + "source": "https://github.com/rectorphp/rector/tree/2.1.6" }, "funding": [ { @@ -10624,7 +11223,7 @@ "type": "github" } ], - "time": "2025-08-15T14:41:36+00:00" + "time": "2025-09-05T15:43:08+00:00" }, { "name": "roave/security-advisories", @@ -10632,12 +11231,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "e3577178d2d0ae7fe287bd4d5d5950b7476e9aed" + "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e3577178d2d0ae7fe287bd4d5d5950b7476e9aed", - "reference": "e3577178d2d0ae7fe287bd4d5d5950b7476e9aed", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/dc5c4ede5c331ae21fb68947ff89672df9b7cc7d", + "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d", "shasum": "" }, "conflict": { @@ -10668,8 +11267,8 @@ "aoe/restler": "<1.7.1", "apache-solr-for-typo3/solr": "<2.8.3", "apereo/phpcas": "<1.6", - "api-platform/core": "<3.4.17|>=4.0.0.0-alpha1,<4.0.22", - "api-platform/graphql": "<3.4.17|>=4.0.0.0-alpha1,<4.0.22", + "api-platform/core": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", "appwrite/server-ce": "<=1.2.1", "arc/web": "<3", "area17/twill": "<1.2.5|>=2,<2.5.3", @@ -10758,9 +11357,9 @@ "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", - "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.9.40|>=4.10,<4.11.7|>=4.13,<4.13.21|>=5.1,<5.1.4", + "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.13.56|>=5,<5.3.38|>=5.4.0.0-RC1-dev,<5.6.1", "contao/core": "<3.5.39", - "contao/core-bundle": "<4.13.54|>=5,<5.3.30|>=5.4,<5.5.6", + "contao/core-bundle": "<4.13.56|>=5,<5.3.38|>=5.4,<5.6.1", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "corveda/phpsandbox": "<1.3.5", @@ -11061,7 +11660,7 @@ "marshmallow/nova-tiptap": "<5.7", "matomo/matomo": "<1.11", "matyhtf/framework": "<3.0.6", - "mautic/core": "<5.2.6|>=6.0.0.0-alpha,<6.0.2", + "mautic/core": "<5.2.8|>=6.0.0.0-alpha,<6.0.5", "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", "maximebf/debugbar": "<1.19", "mdanter/ecc": "<2", @@ -11207,7 +11806,7 @@ "pixelfed/pixelfed": "<0.12.5", "plotly/plotly.js": "<2.25.2", "pocketmine/bedrock-protocol": "<8.0.2", - "pocketmine/pocketmine-mp": "<5.25.2", + "pocketmine/pocketmine-mp": "<5.32.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", @@ -11215,7 +11814,7 @@ "prestashop/blockwishlist": ">=2,<2.1.1", "prestashop/contactform": ">=1.0.1,<4.3", "prestashop/gamification": "<2.3.2", - "prestashop/prestashop": "<8.1.6", + "prestashop/prestashop": "<8.2.3", "prestashop/productcomments": "<5.0.2", "prestashop/ps_contactinfo": "<=3.3.2", "prestashop/ps_emailsubscription": "<2.6.1", @@ -11313,6 +11912,7 @@ "snipe/snipe-it": "<8.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", + "solspace/craft-freeform": ">=5,<5.10.16", "soosyze/soosyze": "<=2", "spatie/browsershot": "<5.0.5", "spatie/image-optimizer": "<1.7.3", @@ -11588,7 +12188,7 @@ "type": "tidelift" } ], - "time": "2025-08-26T23:05:13+00:00" + "time": "2025-09-04T20:05:35+00:00" }, { "name": "sebastian/cli-parser", @@ -12618,16 +13218,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", "shasum": "" }, "require": { @@ -12670,7 +13270,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.2" + "source": "https://github.com/symfony/yaml/tree/v7.3.3" }, "funding": [ { @@ -12690,7 +13290,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-08-27T11:34:33+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/ip.php b/config/ip.php index 0c401f1f9..0579ddc14 100644 --- a/config/ip.php +++ b/config/ip.php @@ -4,9 +4,8 @@ 'date_formats' => [ 'd/m/Y' => date('d/m/Y') . ' (d/m/Y)', 'd-m-Y' => date('d-m-Y') . ' (d-m-Y)', - 'd-M-Y' => date('d-M-Y') . ' (d-M-Y)', - 'd.m.Y' => date('d.m.Y') . ' (d.m.Y)', - 'j.n.Y' => date('j.n.Y') . ' (j.n.Y)', + 'd.M.Y' => date('d.M.Y') . ' (d.M.Y)', + 'j/n/Y' => date('j/n/Y') . ' (j/n/Y)', 'd M,Y' => date('d M,Y') . ' (d M,Y)', 'm/d/Y' => date('m/d/Y') . ' (m/d/Y)', 'm-d-Y' => date('m-d-Y') . ' (m-d-Y)', @@ -36,4 +35,5 @@ '2' => '2', '3' => '3', ], + 'export_version' => 2, ]; diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..75bc9759b --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,7 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN +preserve_hierarchy: true + +files: + - source: /resources/lang/en/**/*.php + translation: /resources/lang/%language%/**/%original_file_name% diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index bdd6091f8..8da139059 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -7,22 +7,22 @@ parameters: path: Modules/Core/Models/Company.php - - message: '#^Method Modules\\Expenses\\Filament\\Company\\Widgets\\RecentExpensesWidget\:\:getTableQuery\(\) should return Illuminate\\Database\\Eloquent\\Builder\|Illuminate\\Database\\Eloquent\\Relations\\Relation\|null but returns Illuminate\\Database\\Query\\Builder\.$#' - identifier: return.type + message: '#^Property Modules\\Core\\Tests\\Unit\\DateFieldAutoPopulationTest\:\:\$company \(Modules\\Core\\Models\\Company\) does not accept Illuminate\\Database\\Eloquent\\Collection\\|Illuminate\\Database\\Eloquent\\Model\.$#' + identifier: assign.propertyType count: 1 - path: Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php + path: Modules/Core/Tests/Unit/DateFieldAutoPopulationTest.php - - message: '#^Cannot access property \$rate on null\.$#' - identifier: property.nonObject + message: '#^Property Modules\\Core\\Tests\\Unit\\DateFieldAutoPopulationTest\:\:\$user \(Modules\\Core\\Models\\User\) does not accept Illuminate\\Database\\Eloquent\\Collection\\|Illuminate\\Database\\Eloquent\\Model\.$#' + identifier: assign.propertyType count: 1 - path: Modules/Invoices/Database/Factories/InvoiceItemFactory.php + path: Modules/Core/Tests/Unit/DateFieldAutoPopulationTest.php - - message: '#^Variable \$attributes on left side of \?\? is never defined\.$#' - identifier: nullCoalesce.variable + message: '#^Method Modules\\Expenses\\Filament\\Company\\Widgets\\RecentExpensesWidget\:\:getTableQuery\(\) should return Illuminate\\Database\\Eloquent\\Builder\|Illuminate\\Database\\Eloquent\\Relations\\Relation\|null but returns Illuminate\\Database\\Query\\Builder\.$#' + identifier: return.type count: 1 - path: Modules/Invoices/Database/Factories/InvoiceItemFactory.php + path: Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php - message: '#^Method Modules\\Payments\\Filament\\Company\\Widgets\\RecentPaymentsWidget\:\:getTableQuery\(\) should return Illuminate\\Database\\Eloquent\\Builder\|Illuminate\\Database\\Eloquent\\Relations\\Relation\|null but returns Illuminate\\Database\\Query\\Builder\.$#' @@ -47,15 +47,3 @@ parameters: identifier: return.type count: 1 path: Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php - - - - message: '#^Cannot access property \$rate on null\.$#' - identifier: property.nonObject - count: 1 - path: Modules/Quotes/Database/Factories/QuoteItemFactory.php - - - - message: '#^Variable \$attributes on left side of \?\? is never defined\.$#' - identifier: nullCoalesce.variable - count: 1 - path: Modules/Quotes/Database/Factories/QuoteItemFactory.php diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index fbe4abfe3..17fed91e9 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -833,6 +833,12 @@ 'years' => 'Years', #endregion + #region EXPORTS + 'export_completed' => 'Your :entity export has completed and :count :rows exported.', + 'export_failed_rows' => ':count :rows failed to export.', + 'row' => 'row|rows', + #endregion + #region AUTHENTICATION 'account_inactive' => 'Your account is inactive. Please contact the administrator.', 'account_inactive_login_denied' => 'Login denied: Your account has been deactivated.',