diff --git a/.env.example b/.env.example index 00f76d27..130ad0af 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,13 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost -APP_TIMEZONE=Europe/Berlin APP_LOCALE=de APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=de_DE APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_STORE=database +# PHP_CLI_SERVER_WORKERS=4 + BCRYPT_ROUNDS=12 LOG_CHANNEL=stack @@ -45,7 +46,7 @@ SESSION_DOMAIN=null #MAIL_PORT=1025 #MAIL_USERNAME=null #MAIL_PASSWORD=null -#MAIL_ENCRYPTION=null +MAIL_SCHEME=null #MAIL_FROM_ADDRESS="hello@example.com" #MAIL_FROM_NAME="${APP_NAME}" diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml new file mode 100644 index 00000000..162dd200 --- /dev/null +++ b/.github/workflows/rector.yml @@ -0,0 +1,40 @@ +name: Rector Code Style Check +on: + - push +jobs: + rector: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, intl + ini-values: post_max_size=256M, max_execution_time=180 + coverage: xdebug + tools: php-cs-fixer, phpunit + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Setup composer cache + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install composer dependencies + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + - name: Rector Cache + uses: actions/cache@v4 + with: + path: /tmp/rector + key: ${{ runner.os }}-rector-${{ github.run_id }} + restore-keys: ${{ runner.os }}-rector- + + - run: mkdir -p /tmp/rector + + - name: Rector Dry Run + run: php vendor/bin/rector process --dry-run --config=rector.php diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml new file mode 100644 index 00000000..a1f1d459 --- /dev/null +++ b/.github/workflows/translations.yml @@ -0,0 +1,31 @@ +name: Translations +on: + - push +jobs: + translations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, intl + ini-values: post_max_size=256M, max_execution_time=180 + coverage: xdebug + tools: php-cs-fixer, phpunit + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Setup composer cache + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install composer dependencies + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + - name: Run translations check + run: php artisan translations:check --excludedDirectories=vendor diff --git a/.gitignore b/.gitignore index 1cb27830..a338fa83 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /public/build /public/storage /storage/*.key +/storage/pail /vendor .env .env.testing @@ -15,6 +16,7 @@ Homestead.yaml npm-debug.log yarn-error.log /.idea +/.nova /.vscode /public/css/app.css /public/js/app.js diff --git a/app/Console/Commands/ConvertLegacyBudgetPlans.php b/app/Console/Commands/ConvertLegacyBudgetPlans.php new file mode 100644 index 00000000..f7c135fc --- /dev/null +++ b/app/Console/Commands/ConvertLegacyBudgetPlans.php @@ -0,0 +1,341 @@ +option('dry-run'); + $planId = $this->option('plan-id'); + $organization = $this->option('organization'); + + if ($dryRun) { + $this->warn('🔍 Running in DRY-RUN mode - no changes will be made'); + } + + // Get legacy plans to convert + $legacyPlans = $planId + ? LegacyBudgetPlan::where('id', $planId)->get() + : LegacyBudgetPlan::all(); + + if ($legacyPlans->isEmpty()) { + $this->error('No legacy budget plans found to convert.'); + + return self::FAILURE; + } + + $this->info("Found {$legacyPlans->count()} legacy budget plan(s) to convert."); + + // CRITICAL: Find global maximum item ID across ALL plans before starting + $this->nextGroupId = $this->findGlobalMaxItemId($legacyPlans) + 1; + $this->line('🔢 Global max legacy item ID: '.($this->nextGroupId - 1)); + $this->line("🔢 Group IDs will start from: {$this->nextGroupId}"); + + DB::beginTransaction(); + + try { + foreach ($legacyPlans as $legacyPlan) { + $this->info("\n📋 Converting Legacy Plan ID: {$legacyPlan->id}"); + + $this->convertPlan($legacyPlan, $organization, $dryRun); + } + + if ($dryRun) { + DB::rollBack(); + $this->warn("\n✅ Dry run completed - no changes were made"); + } else { + DB::commit(); + $this->info("\n✅ Conversion completed successfully!"); + } + + return self::SUCCESS; + + } catch (\Exception $e) { + DB::rollBack(); + $this->error("\n❌ Error during conversion: ".$e->getMessage()); + $this->error($e->getTraceAsString()); + + return self::FAILURE; + } + } + + /** + * Find the global maximum legacy item ID across ALL plans to be converted + */ + protected function findGlobalMaxItemId($legacyPlans): int + { + $maxId = 0; + + foreach ($legacyPlans as $plan) { + $planMaxId = LegacyBudgetItem::whereHas('budgetGroup', function ($query) use ($plan): void { + $query->where('hhp_id', $plan->id); + })->max('id'); + + if ($planMaxId > $maxId) { + $maxId = $planMaxId; + } + } + + // Also check existing budget items in case we're adding to existing data + $existingMaxId = BudgetItem::max('id') ?? 0; + + return max($maxId, $existingMaxId); + } + + /** + * Convert a single legacy budget plan + */ + protected function convertPlan(LegacyBudgetPlan $legacyPlan, string $organization, bool $dryRun): void + { + // Check if plan already exists in new structure + if (BudgetPlan::find($legacyPlan->id)) { + $this->warn(" ⚠️ Budget Plan ID {$legacyPlan->id} already exists in new structure. Skipping."); + + return; + } + + // Create or find fiscal year + $fiscalYear = $this->getOrCreateFiscalYear($legacyPlan, $dryRun); + + // Convert state + $state = $this->convertState($legacyPlan->state); + + // Create new budget plan with the same ID + $newPlan = new BudgetPlan([ + 'organization' => $organization, + 'fiscal_year_id' => $fiscalYear->id, + 'state' => $state, + 'resolution_date' => null, // Legacy doesn't have this + 'approval_date' => null, // Legacy doesn't have this + ]); + + // Force the ID to match the legacy plan + $newPlan->id = $legacyPlan->id; + + if (! $dryRun) { + $newPlan->save(); + } + + $this->line(" ✓ Created Budget Plan (ID: {$newPlan->id}, State: {$state->value})"); + + // Get all legacy groups and items + $legacyGroups = $legacyPlan->budgetGroups()->with('budgetItems')->get(); + $this->line(" 📁 Processing {$legacyGroups->count()} budget groups..."); + + // PASS 1: Create all budget items with preserved IDs + $groupPosition = 0; + foreach ($legacyGroups as $legacyGroup) { + $this->convertItemsFirstPass($legacyGroup, $newPlan, $groupPosition, $dryRun); + $groupPosition++; + } + + // PASS 2: Create group items and update parent_id references + $this->createGroupItems($legacyGroups, $newPlan, $dryRun); + } + + /** + * First pass: Create budget items with their original IDs, temporarily with no parent + */ + protected function convertItemsFirstPass( + LegacyBudgetGroup $legacyGroup, + BudgetPlan $newPlan, + int $groupPosition, + bool $dryRun + ): void { + // Determine budget type from legacy type field (0 = income, 1 = expense) + $budgetType = $legacyGroup->type == 0 ? BudgetType::INCOME : BudgetType::EXPENSE; + + // Assign the next available group ID and increment for next group + $futureGroupId = $this->nextGroupId; + $this->nextGroupId++; + + // Store the mapping for later + $this->groupIdMapping[$legacyGroup->id] = [ + 'new_id' => $futureGroupId, + 'name' => $legacyGroup->gruppen_name, + 'type' => $budgetType, + 'position' => $groupPosition, + 'items' => [], + ]; + + $itemPosition = 0; + foreach ($legacyGroup->budgetItems as $legacyItem) { + if (BudgetItem::find($legacyItem->id)) { + $this->warn(" ⚠️ Budget Item ID {$legacyItem->id} already exists. Skipping."); + + continue; + } + + $newItem = new BudgetItem([ + 'budget_plan_id' => $newPlan->id, + 'short_name' => $legacyItem->titel_nr ?? $this->generateShortName($legacyItem->titel_name), + 'name' => $legacyItem->titel_name, + 'value' => $legacyItem->value, + 'budget_type' => $budgetType, + 'description' => null, + 'parent_id' => null, // Will be updated in pass 2 + 'is_group' => false, + 'position' => $itemPosition, + ]); + + // Force the ID to match the legacy item + $newItem->id = $legacyItem->id; + + if (! $dryRun) { + $newItem->save(); + } + + $this->line(" ✓ Item {$legacyItem->id}: {$legacyItem->titel_name}"); + + // Store the legacy group ID this item belongs to + $this->groupIdMapping[$legacyGroup->id]['items'][] = $legacyItem->id; + + $itemPosition++; + } + + $this->line(" ✓ Group items created: {$legacyGroup->gruppen_name} (will be ID: {$futureGroupId})"); + } + + /** + * Second pass: Create group items and update parent references + */ + protected function createGroupItems($legacyGroups, BudgetPlan $newPlan, bool $dryRun): void + { + $this->line("\n 📦 Creating group items and updating parent references..."); + + foreach ($legacyGroups as $legacyGroup) { + $groupInfo = $this->groupIdMapping[$legacyGroup->id]; + $groupId = $groupInfo['new_id']; + + // Calculate total value for the group + $totalValue = $legacyGroup->budgetItems()->sum('value'); + + // Create the group item with the predetermined ID + $groupItem = new BudgetItem([ + 'budget_plan_id' => $newPlan->id, + 'short_name' => $this->generateShortName($groupInfo['name']), + 'name' => $groupInfo['name'], + 'value' => $totalValue, + 'budget_type' => $groupInfo['type'], + 'description' => null, + 'parent_id' => null, + 'is_group' => true, + 'position' => $groupInfo['position'], + ]); + + $groupItem->id = $groupId; + + if (! $dryRun) { + $groupItem->save(); + } + + $this->line(" ✓ Created Group Item ID {$groupId}: {$groupInfo['name']}"); + + // Update all child items to point to this group + if (! empty($groupInfo['items']) && ! $dryRun) { + $itemCount = BudgetItem::whereIn('id', $groupInfo['items']) + ->update(['parent_id' => $groupId]); + + $this->line(" ↳ Updated {$itemCount} child items to parent ID {$groupId}"); + } elseif (! empty($groupInfo['items'])) { + $this->line(' ↳ Would update '.count($groupInfo['items'])." child items to parent ID {$groupId}"); + } + } + } + + /** + * Get or create fiscal year based on legacy plan dates + */ + protected function getOrCreateFiscalYear(LegacyBudgetPlan $legacyPlan, bool $dryRun): FiscalYear + { + // Try to find existing fiscal year that matches the dates + $fiscalYear = FiscalYear::where('start_date', $legacyPlan->von) + ->where('end_date', $legacyPlan->bis) + ->first(); + + if (! $fiscalYear) { + // Create a new fiscal year + $fiscalYear = new FiscalYear([ + 'start_date' => $legacyPlan->von, + 'end_date' => $legacyPlan->bis ?? $legacyPlan->von->addYear()->subDay(), + ]); + + if (! $dryRun) { + $fiscalYear->save(); + } else { + // In dry-run, we need a mock ID + $fiscalYear->id = 9999; + } + + $this->line(" ✓ Created Fiscal Year: {$fiscalYear->start_date} to {$fiscalYear->end_date}"); + } + + return $fiscalYear; + } + + /** + * Convert legacy state to new BudgetPlanState enum + */ + protected function convertState(?string $state): BudgetPlanState + { + // Adjust this mapping based on your legacy state values + return match ($state) { + 'final', 'approved', '1' => BudgetPlanState::FINAL, + default => BudgetPlanState::DRAFT, + }; + } + + /** + * Generate a short name from a full name + */ + protected function generateShortName(string $fullName): string + { + // Take first 3 words or first 20 characters + $words = explode(' ', $fullName); + $shortName = implode(' ', array_slice($words, 0, 3)); + + return substr($shortName, 0, 20); + } +} diff --git a/app/Console/Commands/LegacyDeleteBudgetPlan.php b/app/Console/Commands/LegacyDeleteBudgetPlan.php index 3d824ee3..8bfdbdca 100644 --- a/app/Console/Commands/LegacyDeleteBudgetPlan.php +++ b/app/Console/Commands/LegacyDeleteBudgetPlan.php @@ -25,7 +25,7 @@ class LegacyDeleteBudgetPlan extends Command /** * Execute the console command. */ - public function handle() + public function handle(): void { $hhp = LegacyBudgetPlan::findOrFail($this->argument('id')); $groups = $hhp->budgetGroups(); @@ -36,7 +36,7 @@ public function handle() return; } - \DB::transaction(function () use ($hhp, $groups, $title) { + \DB::transaction(function () use ($hhp, $groups, $title): void { \Schema::disableForeignKeyConstraints(); $title->delete(); $groups->delete(); diff --git a/app/Console/Commands/LegacyMigrateEncryption.php b/app/Console/Commands/LegacyMigrateEncryption.php index 4fa1e3f6..2051eb7b 100644 --- a/app/Console/Commands/LegacyMigrateEncryption.php +++ b/app/Console/Commands/LegacyMigrateEncryption.php @@ -3,12 +3,13 @@ namespace App\Console\Commands; use App\Models\Legacy\ChatMessage; -use App\Models\Legacy\Expenses; +use App\Models\Legacy\Expense; use Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException; use forms\chat\ChatHandler; use forms\projekte\auslagen\AuslagenHandler2; use Illuminate\Console\Command; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Support\Env; use Illuminate\Support\Facades\DB; class LegacyMigrateEncryption extends Command @@ -32,7 +33,7 @@ class LegacyMigrateEncryption extends Command */ public function handle(): int { - if (! isset($_ENV['CHAT_PRIVATE_KEY'], $_ENV['CHAT_PUBLIC_KEY'], $_ENV['IBAN_SECRET_KEY'])) { + if (! Env::get('CHAT_PRIVATE_KEY') !== null && Env::get('CHAT_PUBLIC_KEY') !== null && Env::get('IBAN_SECRET_KEY') !== null) { $this->error('Please set chat private key and public key / IBAN_SECRET_KEY'); return self::FAILURE; @@ -49,32 +50,32 @@ public function handle(): int if (str_starts_with($message->text, '$enc$')) { // old prefix $text = substr($text, strlen('$enc$')); - $text = ChatHandler::legacyDecryptMessage($text, $_ENV['CHAT_PRIVATE_KEY']); + $text = ChatHandler::legacyDecryptMessage($text, Env::get('CHAT_PRIVATE_KEY')); $message->text = \Crypt::encryptString($text); $message->save(); $count++; } elseif ($message->type === -1) { // not used productive anymore, was "private message" - $text = ChatHandler::legacyDecryptMessage($text, $_ENV['CHAT_PRIVATE_KEY']); + $text = ChatHandler::legacyDecryptMessage($text, Env::get('CHAT_PRIVATE_KEY')); $message->text = \Crypt::encryptString($text); $message->save(); $count++; } } - } catch (WrongKeyOrModifiedCiphertextException $e) { + } catch (WrongKeyOrModifiedCiphertextException) { // do nothing } }); $this->info("Migrated $count chat messages from legacy encryption to laravel integrated"); $count = 0; - Expenses::all()->each(function ($expense) use (&$count): void { - $cryptIban = $expense->getAttribute('zahlung-iban'); + Expense::all()->each(function ($expense) use (&$count): void { + $cryptIban = $expense->getAttribute('zahlung_iban'); try { \Crypt::decryptString($cryptIban); - } catch (DecryptException $d) { + } catch (DecryptException) { $iban = AuslagenHandler2::legacyDecryptStr($cryptIban); - $expense->setAttribute('zahlung-iban', \Crypt::encryptString($iban)); + $expense->setAttribute('zahlung_iban', \Crypt::encryptString($iban)); $expense->etag = \Str::random(32); $expense->save(); $count++; diff --git a/app/Console/Commands/LegacyMigrateFilesToStorage.php b/app/Console/Commands/LegacyMigrateFilesToStorage.php index 578f885d..c8c28bd2 100644 --- a/app/Console/Commands/LegacyMigrateFilesToStorage.php +++ b/app/Console/Commands/LegacyMigrateFilesToStorage.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Models\Legacy\ExpensesReceipt; +use App\Models\Legacy\ExpenseReceipt; use App\Models\Legacy\FileInfo; use Illuminate\Console\Command; @@ -30,7 +30,7 @@ public function handle(): void FileInfo::lazy(20)->each(function (FileInfo $fileInfo): void { $data = $fileInfo->fileData; $link = $fileInfo->link; - $beleg = ExpensesReceipt::find($link); + $beleg = ExpenseReceipt::find($link); $expenses_id = $beleg?->auslagen_id; $pdfData = $data?->data; $hash = $fileInfo->hashname; diff --git a/app/Console/Commands/StuFisHealth.php b/app/Console/Commands/StuFisHealth.php index 005ec1c6..399c4ac4 100644 --- a/app/Console/Commands/StuFisHealth.php +++ b/app/Console/Commands/StuFisHealth.php @@ -23,7 +23,7 @@ class StuFisHealth extends Command /** * Execute the console command. */ - public function handle() + public function handle(): void { $output = collect([ 'version' => config('stufis.version', ''), diff --git a/app/Exports/LegacyBudgetExport.php b/app/Exports/LegacyBudgetExport.php index 928e247a..cfebb9ad 100644 --- a/app/Exports/LegacyBudgetExport.php +++ b/app/Exports/LegacyBudgetExport.php @@ -18,6 +18,7 @@ class LegacyBudgetExport implements FromView, WithColumnFormatting, WithColumnWi public function __construct(public LegacyBudgetPlan $plan) {} + #[\Override] public function view(): View { @@ -29,6 +30,7 @@ public function view(): View ]); } + #[\Override] public function columnFormats(): array { return [ @@ -46,6 +48,7 @@ public function sum(string $column, array|Collection $rows) return '=SUM('.$fields.')'; } + #[\Override] public function columnWidths(): array { return [ diff --git a/app/Http/Controllers/BudgetPlanController.php b/app/Http/Controllers/BudgetPlanController.php index fb478893..63603782 100644 --- a/app/Http/Controllers/BudgetPlanController.php +++ b/app/Http/Controllers/BudgetPlanController.php @@ -5,11 +5,15 @@ use App\Models\BudgetPlan; use App\Models\Enums\BudgetType; use App\Models\FiscalYear; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Gate; class BudgetPlanController extends Controller { public function index() { + Gate::authorize('viewAny', BudgetPlan::class); + $years = FiscalYear::orderByDesc('start_date')->get(); $orphaned_plans = BudgetPlan::doesntHave('fiscalYear')->get(); @@ -20,12 +24,18 @@ public function index() public function show(int $plan_id) { $plan = BudgetPlan::findOrFail($plan_id); + Gate::authorize('view', $plan); + $items = [ + BudgetType::INCOME->slug() => $plan->budgetItemsTree(BudgetType::INCOME), + BudgetType::EXPENSE->slug() => $plan->budgetItemsTree(BudgetType::EXPENSE), + ]; - return view('budget-plan.show', ['plan' => $plan]); + return view('budget-plan.view', ['plan' => $plan, 'items' => $items]); } - public function create() + public function create(): RedirectResponse { + Gate::authorize('create', BudgetPlan::class); $plan = BudgetPlan::create(['state' => 'draft']); $groups = $plan->budgetItems()->createMany([ ['is_group' => 1, 'budget_type' => BudgetType::INCOME, 'position' => 0, 'short_name' => 'E1'], @@ -42,6 +52,6 @@ public function create() ]); }); - return redirect()->route('budget-plan.edit', ['plan_id' => $plan->id]); + return to_route('budget-plan.edit', ['plan_id' => $plan->id]); } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index e749914f..2e8af07a 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,12 +2,4 @@ namespace App\Http\Controllers; -use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Foundation\Bus\DispatchesJobs; -use Illuminate\Foundation\Validation\ValidatesRequests; -use Illuminate\Routing\Controller as BaseController; - -abstract class Controller extends BaseController -{ - use AuthorizesRequests, DispatchesJobs, ValidatesRequests; -} +abstract class Controller {} diff --git a/app/Http/Controllers/Legacy/DeleteExpenses.php b/app/Http/Controllers/Legacy/DeleteExpenses.php index 7b729d0a..64d77d50 100644 --- a/app/Http/Controllers/Legacy/DeleteExpenses.php +++ b/app/Http/Controllers/Legacy/DeleteExpenses.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Legacy; use App\Http\Controllers\Controller; -use App\Models\Legacy\Expenses; -use App\Models\Legacy\ExpensesReceipt; +use App\Models\Legacy\Expense; +use App\Models\Legacy\ExpenseReceipt; use App\Models\Legacy\FileInfo; use framework\auth\AuthHandler; use Illuminate\Support\Facades\DB; @@ -13,16 +13,16 @@ class DeleteExpenses extends Controller { public function __invoke(int $expense_id) { - $expense = Expenses::findOrFail($expense_id); + $expense = Expense::findOrFail($expense_id); $project = $expense->project; // authorize user $userPerm = AuthHandler::getInstance()->hasGroup('ref-finanzen-hv') || $project->creator->id === \Auth::user()->id - || explode(';', $expense->created)[1] === \Auth::user()->username; + || explode(';', (string) $expense->created)[1] === \Auth::user()->username; // authorize state - $deletableState = ! in_array(explode(';', $expense->state)[0], ['instructed', 'booked'], true); + $deletableState = ! in_array(explode(';', (string) $expense->state)[0], ['instructed', 'booked'], true); if ($userPerm === false || $deletableState === false) { abort(403); @@ -30,7 +30,7 @@ public function __invoke(int $expense_id) // to make sure to delete everything and not only parts \DB::beginTransaction(); $reciepts = $expense->receipts; - $reciepts->each(function (ExpensesReceipt $receipt): void { + $reciepts->each(function (ExpenseReceipt $receipt): void { // delete all posts $receipt->posts()->delete(); // delete all files db entries (storage later) @@ -53,6 +53,6 @@ public function __invoke(int $expense_id) }); \DB::commit(); - return redirect()->route('legacy.dashboard', ['sub' => 'mygremium']); + return to_route('legacy.dashboard', ['sub' => 'mygremium']); } } diff --git a/app/Http/Controllers/Legacy/DeleteProject.php b/app/Http/Controllers/Legacy/DeleteProject.php index c4598de3..9ad9ec63 100644 --- a/app/Http/Controllers/Legacy/DeleteProject.php +++ b/app/Http/Controllers/Legacy/DeleteProject.php @@ -25,6 +25,6 @@ public function __invoke(int $project_id) $project->posts()->delete(); $project->delete(); - return redirect()->route('legacy.dashboard', ['sub' => 'mygremium']); + return to_route('legacy.dashboard', ['sub' => 'mygremium']); } } diff --git a/app/Http/Controllers/Legacy/ExportController.php b/app/Http/Controllers/Legacy/ExportController.php index 9d2702ec..fd388a72 100644 --- a/app/Http/Controllers/Legacy/ExportController.php +++ b/app/Http/Controllers/Legacy/ExportController.php @@ -5,7 +5,7 @@ use App\Exports\LegacyBudgetExport; use App\Http\Controllers\Controller; use App\Models\Legacy\LegacyBudgetPlan; -use Carbon\Carbon; +use Illuminate\Support\Facades\Date; use Maatwebsite\Excel\Excel; class ExportController extends Controller @@ -18,10 +18,10 @@ public function budgetPlan(int $id, string $filetype) }; $plan = LegacyBudgetPlan::findOrFail($id); $today = today()->format('Y-m-d'); - $start = Carbon::make($plan->von)?->format('y-m'); - $end = Carbon::make($plan->bis)?->format('y-m'); + $start = Date::make($plan->von)?->format('y-m'); + $end = Date::make($plan->bis)?->format('y-m'); $fileName = "$today HHP $start".($end ? " bis $end" : '').".$filetype"; - return (new LegacyBudgetExport($plan))->download($fileName, $writerType); + return new LegacyBudgetExport($plan)->download($fileName, $writerType); } } diff --git a/app/Http/Controllers/Legacy/LegacyController.php b/app/Http/Controllers/Legacy/LegacyController.php index 10fdcc1b..b5c1b550 100644 --- a/app/Http/Controllers/Legacy/LegacyController.php +++ b/app/Http/Controllers/Legacy/LegacyController.php @@ -73,7 +73,7 @@ public function belegePdf(int $project_id, int $auslagen_id, int $version, ?stri ]); $ah->generate_belege_pdf(); $path = route('legacy.belege-pdf', [ - 'project_id' => $project_id, + 'projekt_id' => $project_id, 'auslagen_id' => $auslagen_id, 'version' => $version, 'file_name' => "Belege-IP$project_id-A$auslagen_id.pdf", @@ -100,7 +100,7 @@ public function zahlungsanweisungPdf(int $project_id, int $auslagen_id, int $ver ]); $ah->generate_zahlungsanweisung_pdf(); $path = route('legacy.zahlungsanweisung-pdf', [ - 'project_id' => $project_id, + 'projekt_id' => $project_id, 'auslagen_id' => $auslagen_id, 'version' => $version, 'file_name' => "Zahlungsanweisung-IP$project_id-A$auslagen_id.pdf", diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php new file mode 100644 index 00000000..656104f8 --- /dev/null +++ b/app/Http/Controllers/ProjectController.php @@ -0,0 +1,75 @@ +file(Storage::path($attachment->path)); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index a053d2c1..2a7536f0 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -10,6 +10,7 @@ class Authenticate extends Middleware { + #[\Override] public function handle($request, Closure $next, ...$guards): Response { // do it like in the parent @@ -27,6 +28,7 @@ public function handle($request, Closure $next, ...$guards): Response /** * Get the path the user should be redirected to when they are not authenticated. */ + #[\Override] protected function redirectTo(Request $request) { if (! $request->expectsJson()) { diff --git a/app/Livewire/Budgetplan/BudgetPlanEdit.php b/app/Livewire/BudgetPlan/BudgetPlanEdit.php similarity index 73% rename from app/Livewire/Budgetplan/BudgetPlanEdit.php rename to app/Livewire/BudgetPlan/BudgetPlanEdit.php index baa494a8..d9b96d71 100644 --- a/app/Livewire/Budgetplan/BudgetPlanEdit.php +++ b/app/Livewire/BudgetPlan/BudgetPlanEdit.php @@ -1,11 +1,12 @@ get()->keyBy('id'); foreach ($all_items as $item) { - // registers new Livewire ItemForms, there is not yet a native way + // registers new Livewire ItemForms; there is not yet a native way // to generate a dynamic amount of ItemForms, or even multiple $form = new ItemForm($this, 'items.'.$item->id); $form->setItem($item); @@ -80,7 +81,7 @@ public function render() $in_ids = $this->query(1)->whereNull('parent_id')->pluck('id'); $out_ids = $this->query(-1)->whereNull('parent_id')->pluck('id'); - return view('livewire.budgetplan.plan-edit', [ + return view('livewire.budget-plan.plan-edit', [ 'fiscal_years' => $fiscal_years, 'all_items' => $item_models, 'root_items' => [ @@ -90,7 +91,16 @@ public function render() ]); } - public function updatedItems($value, $property): void + /** + * Handle the updated event for an item's property. + * This method processes changes to item properties and updates the corresponding record in the database. + * If the updated property is `value`, it triggers a recalculation of the item's and its parents values. + * After the update, the method refreshes the component state. + * + * @param mixed $value The new value for the item's property. + * @param string $property The property identifier in the format "item_id.property_name". + */ + public function updatedItems(mixed $value, string $property): void { [$item_id, $item_prop] = explode('.', $property, 2); if (in_array($item_prop, ['short_name', 'name', 'value'])) { @@ -99,26 +109,42 @@ public function updatedItems($value, $property): void if ($item_prop === 'value') { $this->reSumItemValues($item); } - Flux::toast('Your changes have been saved.', variant: 'success'); + Flux::toast('FIXME: Your changes have been saved.', variant: 'success'); $this->refresh(); } } + /** + * Recalculate and update the values of parent budget items by summing the values of their child items. + * This method propagates updates upwards through the hierarchy of budget items, starting from a given leaf item. + * Each parent's value is recalculated based on the sum of its direct children's values, and the changes are saved to the database. + * + * @param BudgetItem $leafItem The leaf budget item from which the upward recalculation begins. + */ public function reSumItemValues(BudgetItem $leafItem): void { $item = $leafItem; // iterate upwards until there is no parent left while (($item = $item->parent) !== null) { - $value = $item->children()->sum('value'); + $amount = $item->children()->sum('value'); + $money = Money::EUR($amount, true); // update db model - $item->value = $value; + $item->value = $money; $item->save(); // update frontend - $this->items[$item->id]->value = $value; + $this->items[$item->id]->value = $money; } } - public function updated($property): void + /** + * Handle the updated event for the specified property. + * This method is called whenever a property is updated. + * It updates the corresponding property in the model and saves the changes. + * Only the meta-data directly in the BudgetPlan Model is updated here. + * + * @param string $property The property name that has been updated. + */ + public function updated(string $property): void { if (in_array($property, ['organization', 'fiscal_year_id', 'resolution_date', 'approval_date'])) { $value = $this->$property; @@ -126,7 +152,7 @@ public function updated($property): void $plan->update([ $property => $value, ]); - Flux::toast(text: "$property -> $value", heading: 'Your changes have been saved.', variant: 'success'); + Flux::toast(text: "$property -> $value", heading: 'FIXME: Your changes have been saved.', variant: 'success'); } } @@ -141,7 +167,7 @@ public function sort($item_id, $new_position): void } // pickup all items between old and new position - $block = BudgetItem::whereBetween('position', [ + $block = $item->siblings()->whereBetween('position', [ min($current_position, $new_position), max($current_position, $new_position), ]); @@ -155,9 +181,10 @@ public function sort($item_id, $new_position): void } $item->update(['position' => $new_position]); + }); - Flux::toast('Dragging and dropping', variant: 'success'); + Flux::toast('FIXME: Dragging and dropping', variant: 'success'); } public function save() @@ -172,7 +199,7 @@ public function save() 'organization' => $this->organization, ]); - return $this->redirect(route('budget-plan.index')); + $this->redirect(route('budget-plan.view', $this->plan_id)); } public function addGroup(BudgetType $budget_type): void @@ -184,6 +211,7 @@ public function addGroup(BudgetType $budget_type): void 'budget_type' => $budget_type, 'is_group' => true, 'position' => $newPos, + 'value' => Money::EUR(100), ]); $form = new ItemForm($this, 'items.'.$new_item->budget_type->slug().'.'.$new_item->id); $form->setItem($new_item); @@ -192,9 +220,9 @@ public function addGroup(BudgetType $budget_type): void $this->addBudget($new_item->id); } - public function addBudget(int $parent_id): void + public function addBudget(int $parent_id, float $value = 0.0): void { - $this->addItem($parent_id, false); + $this->addItem($parent_id, false, $value); } public function addSubGroup(int $parent_id): void @@ -202,7 +230,7 @@ public function addSubGroup(int $parent_id): void $this->addItem($parent_id, true); } - private function addItem(int $parent_id, bool $is_group): void + private function addItem(int $parent_id, bool $is_group, $value = 0.0): void { $parent = BudgetItem::findOrFail($parent_id); if ($parent->is_group === 0) { @@ -216,6 +244,7 @@ private function addItem(int $parent_id, bool $is_group): void 'budget_type' => $parent->budget_type, 'is_group' => $is_group, 'position' => $pos + 1, + 'value' => Money::EUR($value, true), ]); $form = new ItemForm($this, 'items.'.$new_item->budget_type->slug().'.'.$new_item->id); $form->setItem($new_item); @@ -230,6 +259,7 @@ public function convertToGroup(int $item_id): void return; } $item->update(['is_group' => true]); + $this->addBudget($item->id, $item->value->getAmount() / 100); } public function convertToBudget(int $item_id): void @@ -278,6 +308,14 @@ public function delete(int $item_id): void return; } $item->delete(); + $this->resumItemValues($item); + } + + public function resetPositions(): void + { + $plan = BudgetPlan::findOrFail($this->plan_id); + $plan->normalizePositions(); + $this->refresh(); } public function refresh(): void diff --git a/app/Livewire/Budgetplan/ItemForm.php b/app/Livewire/BudgetPlan/ItemForm.php similarity index 72% rename from app/Livewire/Budgetplan/ItemForm.php rename to app/Livewire/BudgetPlan/ItemForm.php index 30b8fa37..4c6d173f 100644 --- a/app/Livewire/Budgetplan/ItemForm.php +++ b/app/Livewire/BudgetPlan/ItemForm.php @@ -1,8 +1,9 @@ id = $item->id; - $this->postion = $item->postion; + $this->position = $item->position; $this->is_group = $item->is_group; $this->name = $item->name; $this->short_name = $item->short_name; - $this->value = $item->value ?? 0; + // $this->value = ($item->value?->getAmount() ?? 0) / 100 ; + $this->value = $item->value; } } diff --git a/app/Livewire/ChatPanel.php b/app/Livewire/ChatPanel.php new file mode 100644 index 00000000..a4431824 --- /dev/null +++ b/app/Livewire/ChatPanel.php @@ -0,0 +1,51 @@ +targetType = $targetType; + $this->targetId = $targetId; + } + + public function render() + { + /** @var Collection $messages */ + $messages = ChatMessage::where('target', $this->targetType) + ->where('target_id', $this->targetId)->get(); + + return view('livewire.chat-panel', ['messages' => $messages]); + } + + public function save() + { + + $cleanContent = $this->validate(['content' => ['required', 'min:1', new FluxEditorRule]])['content']; + + ChatMessage::create([ + 'text' => $cleanContent, + 'type' => ChatMessageType::PUBLIC, + 'target' => $this->targetType, + 'target_id' => $this->targetId, + 'creator' => Auth()->user()->username, + 'creator_alias' => Auth()->user()->name, + 'timestamp' => now(), + ]); + + $this->content = ''; + } +} diff --git a/app/Livewire/CreateAntrag.php b/app/Livewire/CreateAntrag.php index 7c5ffc39..5df36bb6 100644 --- a/app/Livewire/CreateAntrag.php +++ b/app/Livewire/CreateAntrag.php @@ -5,8 +5,7 @@ use App\Livewire\Forms\ActorForm; use App\Livewire\Forms\FundingRequestForm; use App\Livewire\Forms\ProjectBudgetForm; -use App\Livewire\Forms\ProjectForm; -use App\Models\Actor; +use App\Models\PtfProject\Actor; use Livewire\Attributes\Url; use Livewire\Component; @@ -19,7 +18,7 @@ class CreateAntrag extends Component public ActorForm $organisationForm; - public ProjectForm $projectForm; + public $projectForm; // might have been deleted at the rework of the project view public ProjectBudgetForm $projectBudgetForm; diff --git a/app/Livewire/FiscalYear/EditFiscalYear.php b/app/Livewire/FiscalYear/EditFiscalYear.php new file mode 100644 index 00000000..edccee8d --- /dev/null +++ b/app/Livewire/FiscalYear/EditFiscalYear.php @@ -0,0 +1,60 @@ +id = $year_id; + if ($this->id) { + // edit + $fiscal_year = FiscalYear::find($this->id); + $this->start_date = $fiscal_year->start_date->format('Y-m-d'); + $this->end_date = $fiscal_year->end_date->format('Y-m-d'); + } else { + // create with suggestions + $lastYear = FiscalYear::orderBy('end_date', 'desc')->limit(1)->first(); + if ($lastYear) { + $this->start_date = $lastYear->end_date->addDay()->format('Y-m-d'); + $this->end_date = $lastYear->end_date->addYear()->format('Y-m-d'); + } + } + } + + public function rules(): array + { + return [ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]; + } + + public function save() + { + $this->validate(); + FiscalYear::updateOrCreate([ + 'id' => $this->id, + ], [ + 'start_date' => $this->start_date, + 'end_date' => $this->end_date, + ]); + $this->redirect(route('budget-plan.index')); + } + + public function render() + { + return view('livewire.fiscal-year.edit-fiscal-year'); + } +} diff --git a/app/Livewire/Forms/ActorForm.php b/app/Livewire/Forms/ActorForm.php index 831cb4c0..66b4dde0 100644 --- a/app/Livewire/Forms/ActorForm.php +++ b/app/Livewire/Forms/ActorForm.php @@ -2,7 +2,7 @@ namespace App\Livewire\Forms; -use App\Models\Actor; +use App\Models\PtfProject\Actor; use Intervention\Validation\Rules\Bic; use Intervention\Validation\Rules\Iban; use Intervention\Validation\Rules\Postalcode; diff --git a/app/Livewire/Forms/FundingRequestForm.php b/app/Livewire/Forms/FundingRequestForm.php index 8eea0131..e5f56924 100644 --- a/app/Livewire/Forms/FundingRequestForm.php +++ b/app/Livewire/Forms/FundingRequestForm.php @@ -2,8 +2,8 @@ namespace App\Livewire\Forms; -use App\Models\Application; -use App\Models\Project; +use App\Models\Legacy\Project; +use App\Models\PtfProject\Application; use Livewire\Form; class FundingRequestForm extends Form diff --git a/app/Livewire/Forms/ProjectForm.php b/app/Livewire/Forms/ProjectForm.php deleted file mode 100644 index 9f78e352..00000000 --- a/app/Livewire/Forms/ProjectForm.php +++ /dev/null @@ -1,38 +0,0 @@ - 'required|min:3', - 'start_date' => 'required|date', - 'end_date' => 'required|date', - 'description' => 'required|min:3', - 'target_group' => 'required', - 'student_body_duties' => 'required|array', - ]; - } - - public function store(): void - { - $this->validate(); - - Project::create($this->all()); - } -} diff --git a/app/Livewire/Project/EditProject.php b/app/Livewire/Project/EditProject.php new file mode 100644 index 00000000..631a618e --- /dev/null +++ b/app/Livewire/Project/EditProject.php @@ -0,0 +1,354 @@ +isNew = is_null($this->project_id); + + if ($this->isNew) { + Gate::authorize('create', Project::class); + $project = new Project; + $this->populateData($project); + $this->addEmptyPost(); + } else { + $project = Project::findOrFail($this->project_id); + Gate::authorize('update', $project); + $this->populateData($project); + } + } + + private function populateData(Project $project): void + { + $this->name = $project->name ?? ''; + $this->responsible = $project->responsible ?? ''; + $this->org = $project->org ?? ''; + $this->org_mail = $project->org_mail ?? ''; + $this->protokoll = $project->protokoll ?? ''; + $this->beschreibung = $project->beschreibung ?? ''; + $this->recht = $project->recht ?? ''; + $this->recht_additional = $project->recht_additional ?? ''; + $this->dateRange = [ + 'start' => $project->date_start ?? null, + 'end' => $project->date_end ?? null, + ]; + $this->version = $project->version ?? 1; + $this->hhp_id = LegacyBudgetPlan::findByDate($project->createdat)?->id; + $this->state_name = $project->state->getValue(); + $this->posts = $project->posts->map(fn (ProjectPost $post) => [ + 'id' => $post->id, + 'name' => $post->name, + 'bemerkung' => $post->bemerkung ?? '', + 'einnahmen' => $post->einnahmen, + 'ausgaben' => $post->ausgaben, + 'titel_id' => $post->titel_id, + ])->all(); + $this->existingAttachments = $project->attachments->map( + fn($attachment) => $attachment->only('id', 'path', 'name', 'mime_type', 'size') + )->all(); + } + + /** + * Translates the livewire properties into the ones expected by the validator and the project model. + * @return array + */ + private function getValues(): array + { + return [ + 'name' => $this->name, + 'responsible' => $this->responsible, + 'org' => $this->org, + 'org_mail' => $this->org_mail, + 'protokoll' => $this->protokoll, + 'beschreibung' => $this->beschreibung, + 'recht' => $this->recht, + 'recht_additional' => $this->recht_additional, + // make compatible with legacy database + 'date_start' => $this->dateRange['start'] ?? null, + 'date_end' => $this->dateRange['end'] ?? null, + 'version' => $this->version, + 'createdat' => Date::parse(LegacyBudgetPlan::find($this->hhp_id)->von)->addDays(7), + 'posts' => $this->posts, + ]; + } + + /** + * Add an empty post row + */ + public function addEmptyPost(): void + { + $this->posts[] = ([ + 'name' => '', + 'bemerkung' => '', + 'einnahmen' => Money::EUR(0), + 'ausgaben' => Money::EUR(0), + 'titel_id' => null, + ]); + } + + public function isPostDeletable(int $index): bool + { + return count($this->posts) > 1 && ( + (isset($this->posts[$index]['id']) && ExpenseReceiptPost::where('projekt_posten_id', $this->posts[$index]['id'])->doesntExist()) + || ! isset($this->posts[$index]['id']) + ); + } + + /** + * Remove a post by index + */ + public function removePost(int $index): void + { + if ($this->isPostDeletable($index)) { + unset($this->posts[$index]); + } + } + + /** + * Save the project + */ + public function saveAs($stateName) + { + if($this->isNew){ + $this->authorize('create', Project::class); + }else{ + $this->authorize('update', $this->getProject()); + } + $state = ProjectState::make($stateName, $this->getProject() ?? new Project); + $validator = Validator::make( + $this->getValues() + [ + 'uploads' => $this->newAttachments, + 'deletedAttachments' => $this->deletedAttachmentIds + ], + $state->rules() + ['uploads.*' => + File::types(['pdf', 'xlsx', 'ods'])->extensions(['pdf', 'xlsx', 'ods'])->max("5 Mb"), + 'deletedAttachments' => 'array', + 'deletedAttachments.*' => 'integer', + ] + ); + $filtered = collect($validator->validate()); + $filteredPosts = $filtered->pull('posts') ?? []; + $newAttachments = $filtered->pull('uploads') ?? []; + $deletedAttachmentIds = $filtered->pull('deletedAttachments') ?? []; + $filteredMeta = $filtered->all(); + + try { + DB::beginTransaction(); + if ($this->isNew) { + $project = Project::create([ + 'creator_id' => Auth::id(), + 'stateCreator_id' => Auth::id(), + ...$filteredMeta, + ]); + } else { + $project = Project::findOrFail($this->project_id); + // Check if the project has been modified since the last load + if ($project->version !== $this->version) { + $this->addError('save', 'Das Projekt wurde zwischenzeitlich von jemand anderem bearbeitet. Bitte laden Sie die Seite neu.'); + + return; + } + $project->update([ + ...$filteredMeta, + 'version' => $project->version + 1, + ]); + } + + if(!$project->state->equals($state)){ + $project->state->transitionTo($state); + } + + foreach ($filteredPosts as $post) { + if (isset($post['id'])) { + $project->posts()->findOrFail($post['id'])->update($post); + } else { + $project->posts()->create($post); + } + } + + foreach ($newAttachments as $attachment){ + $attachment->store('projects/'.$project->id); + $project->attachments()->create([ + 'path' => "projects/$project->id/{$attachment->hashName()}", + 'name' => $attachment->getClientOriginalName(), + 'mime_type' => $attachment->getMimeType(), + 'size' => $attachment->getSize(), + ]); + } + + foreach ($deletedAttachmentIds as $id){ + $pa = ProjectAttachment::where('id', $id)->where('projekt_id', $this->project_id)->findOrFail(); + \Storage::delete($pa->path); + $pa->delete(); + } + + DB::commit(); + + return to_route('project.show', $project->id); + } catch (\Exception $e) { + DB::rollBack(); + $this->addError('save', 'Fehler beim Speichern: '.$e->getMessage()); + } + } + + /** + * Get the sum of all income posts + */ + public function getTotalIncome(): Money + { + return collect($this->posts)->reduce(fn (?Money $carry, array $post) => $carry ? $carry->add($post['einnahmen']) : $post['einnahmen'], Money::EUR(0)); + } + + /** + * Get the sum of all expense posts + */ + public function getTotalExpenses(): Money + { + return collect($this->posts)->reduce(fn (?Money $carry, array $post) => $carry ? $carry->add($post['ausgaben']) : $post['ausgaben'], Money::EUR(0)); + } + + public function removeExistingAttachment(int $id): void + { + $this->deletedAttachmentIds[] = $id; + $this->existingAttachments = array_filter( + $this->existingAttachments, + fn($a) => $a['id'] !== $id + ); + } + + public function removeNewAttachment(int|string $index): void + { + $this->newAttachments[$index]->delete(); + unset($this->newAttachments[$index]); + $this->newAttachments = array_values($this->newAttachments); + } + + /** + * Get budget title options based on the project creation date + */ + protected function getBudgetTitleOptions(): \Illuminate\Database\Eloquent\Collection + { + $plan = LegacyBudgetPlan::findOrFail($this->hhp_id); + + return $plan->budgetItems; + } + + /** + * Get Rechtsgrundlagen options + */ + protected function getRechtsgrundlagenOptions(): array + { + $rechtsgrundlagen = config('stufis.project_legal', []); + + return collect($rechtsgrundlagen)->map(fn ($def, $key) => [ + 'key' => $key, + 'label' => $def['label'] ?? $key, + 'placeholder' => $def['placeholder'] ?? '', + 'label_additional' => $def['label-additional'] ?? 'Zusatzinformationen', + 'hint' => $def['hint-text'] ?? '', + 'has_additional' => isset($def['placeholder'], $def['label-additional']), + ])->all(); + } + + /** + * Get mailing list options + */ + protected function getMailingListOptions(): array + { + $hasFinanceGroup = Auth::user()->getGroups()->contains('ref-finanzen'); + + if ($hasFinanceGroup) { + return config('org_data.mailinglists', []); + } + + // Return only user's mailing lists + return Auth::user()->mailinglists ?? []; + } + + public function render() + { + $gremien = Auth::user()->getCommittees(); + $mailingLists = []; + $rechtsgrundlagen = $this->getRechtsgrundlagenOptions(); + $budgetTitles = $this->getBudgetTitleOptions(); + $state = $this->getState(); + $budgetPlans = LegacyBudgetPlan::all(); + + return view('livewire.project.edit-project', compact( + 'gremien', 'mailingLists', 'budgetTitles', 'rechtsgrundlagen', 'state', 'budgetPlans' + )); + } + + #[Computed] + public function getState(): ProjectState + { + return ProjectState::make($this->state_name, $this->getProject() ?? new Project); + } + + #[Computed] + public function getProject(): ?Project + { + return Project::find($this->project_id); + } +} diff --git a/app/Livewire/Project/ShowProject.php b/app/Livewire/Project/ShowProject.php new file mode 100644 index 00000000..16f8869d --- /dev/null +++ b/app/Livewire/Project/ShowProject.php @@ -0,0 +1,65 @@ +project_id); + $state = $project->state; + + $showApproval = \Auth::user()->getGroups()->has('ref-finanzen-hv') || !$state->equals(Draft::class); + + return view('livewire.project.show-project', compact('project', 'showApproval')); + } + + public function changeState(): void + { + // check if given state string a valid state for this project + $project = Project::findOrFail($this->project_id); + $filtered = $this->validate(['newState' => ['required', new ValidStateRule(ProjectState::class)]]); + $newState = ProjectState::make($filtered['newState'], $project); + // Business Logic check: are some values missing for the new state + $v = $newState->getValidator(); + $v->validate(); + // Authorization check: can the user transition to this state + $this->authorize('transition-to', [$project, $newState]); + + try { + $oldState = $project->state; + $project->state->transitionTo($this->newState); + ChatMessage::create([ + 'text' => "{$oldState->label()} -> {$newState->label()}", + 'type' => ChatMessageType::SYSTEM, + 'target' => 'projekt', + 'target_id' => $project->id, + 'creator' => \Auth::user()->username, + 'creator_alias' => \Auth::user()->name, + 'timestamp' => now(), + ]); + Flux::modal('state-modal')->close(); + $this->reset('newState'); + } catch (CouldNotPerformTransition $e) { + $this->addError('newState', $e->getMessage()); + } + } +} diff --git a/app/Livewire/ProjectOverview.php b/app/Livewire/ProjectOverview.php new file mode 100644 index 00000000..d3bfef54 --- /dev/null +++ b/app/Livewire/ProjectOverview.php @@ -0,0 +1,130 @@ +hhpId === null) { + $this->hhpId = LegacyBudgetPlan::latest()?->id; + } + } + + #[Computed] + public function budgetPlans(): Collection + { + return LegacyBudgetPlan::orderBy('id', 'desc')->get(); + } + + #[Computed] + public function currentBudgetPlan(): ?LegacyBudgetPlan + { + return LegacyBudgetPlan::find($this->hhpId); + } + + #[Computed] + public function userCommittees(): array + { + return resolve(AuthService::class)->userCommittees()->toArray(); + } + + #[Computed] + public function projectsByCommittee(): Collection + { + $budgetPlan = $this->currentBudgetPlan(); + if (! $budgetPlan) { + return []; + } + + $query = Project::query() + ->with(['posts', 'expenses.receipts.posts']) + ->withSum('posts as total_ausgaben', 'ausgaben') + ->withSum('posts as total_einnahmen', 'einnahmen') + ->where('createdat', '>=', $budgetPlan->von); + + if ($budgetPlan->bis) { + $query->where('createdat', '<=', $budgetPlan->bis); + } + + // Apply tab-specific filters + switch ($this->tab) { + case 'mygremium': + $committees = $this->userCommittees; + if (empty($committees)) { + return []; + } + $query->where(function ($q) use ($committees): void { + $q->whereIn('org', $committees) + ->orWhereNull('org') + ->orWhere('org', ''); + }); + break; + + case 'allgremium': + // No additional filter, show all + break; + + case 'open-projects': + $query->whereNotIn('state', ['terminated', 'revoked']) + ->where('state', 'not like', '%terminated%') + ->where('state', 'not like', '%revoked%'); + break; + } + + $projects = $query->orderBy('org')->orderBy('id', 'desc')->get(); + + // Group by committee + return $projects->groupBy(fn ($project) => $project->org ?: ''); + } + + #[Computed] + public function expensesByProjectId(): array + { + $projectIds = collect($this->projectsByCommittee) + ->flatten(1) + ->pluck('id') + ->toArray(); + + if (empty($projectIds)) { + return []; + } + + return Expense::query() + ->with(['receipts.posts']) + ->whereIn('projekt_id', $projectIds) + ->get() + ->groupBy('projekt_id') + ->toArray(); + } + + public function setTab(string $tab): void + { + $this->tab = $tab; + } + + public function setBudgetPlan(int $id): void + { + $this->hhpId = $id; + } + + public function render() + { + return view('livewire.project-overview'); + } +} diff --git a/app/Livewire/TransactionImportWire.php b/app/Livewire/TransactionImportWire.php index b53de1fd..71c0d324 100644 --- a/app/Livewire/TransactionImportWire.php +++ b/app/Livewire/TransactionImportWire.php @@ -10,6 +10,7 @@ use App\Rules\CsvTransactionImport\IbanColumnRule; use App\Rules\CsvTransactionImport\MoneyColumnRule; use Flux\Flux; +use forms\projekte\auslagen\AuslagenHandler2; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Support\Collection; @@ -131,10 +132,10 @@ private function parseCSV(): void $this->separator = $amountSemicolon > $amountComma ? ';' : ','; // extract header and data, explode data with csv separator guesses above - $this->header = str_getcsv((string) $lines->first(), $this->separator); + $this->header = str_getcsv((string) $lines->first(), $this->separator, escape: '\\'); $this->data = $lines->except(0) ->reject(fn ($line): bool => empty($line) || Regex::match('/^(,*|;*)\r?\n?$/', $line)->hasMatch()) - ->map(fn ($line) => str_getcsv((string) $line, $this->separator)) + ->map(fn ($line) => str_getcsv((string) $line, $this->separator, escape: '\\')) ->map(function ($lineArray) { // normalize data foreach ($lineArray as $key => $cell) { @@ -210,11 +211,11 @@ public function save() $transaction->$db_col_name = $this->formatDataDb($row[$this->mapping[$db_col_name]], $db_col_name); } elseif ($db_col_name === 'saldo') { $currentValue = str($row[$this->mapping['value']])->replace(',', '.'); - $currentBalance = bcadd($currentBalance, (string) $currentValue, 2); + $currentBalance = bcadd((string) $currentBalance, (string) $currentValue, 2); $transaction->$db_col_name = $this->formatDataDb($currentBalance, $db_col_name); } } - + AuslagenHandler2::hookZahlung($transaction->zweck); $transaction->save(); } try { @@ -242,7 +243,7 @@ public function save() // $this->redirectRoute('legacy.konto', ['account_id' => $this->account_id]); // return redirect()->route('konto.import.manual', ['account_id' => $this->account_id]) - return redirect()->route('legacy.konto', ['konto' => $this->account_id]) + return to_route('legacy.konto', ['konto' => $this->account_id]) ->with(['message' => __('konto.csv-import-success-msg', ['new-saldo' => $newBalance, 'transaction-amount' => $this->data->count()])]); } @@ -311,7 +312,7 @@ public function formatDataDb(string|int $value, string $db_col_name): int|string // if($type === 'decimal') dd([$value, (float) $value,$db_col_name]); return match ($type) { 'integer' => (int) $value, - 'date' => guessCarbon($value, 'Y-m-d'), + 'date' => guessDate($value, 'Y-m-d'), 'decimal' => $value, // no casting needed, string is expected default => $value, }; @@ -328,7 +329,7 @@ public function formatDataView(string|int $value, string $db_col_name): int|stri } return match ($type) { - 'date' => guessCarbon($value, 'd.m.Y'), + 'date' => guessDate($value, 'd.m.Y'), 'decimal' => number_format((float) $value, 2, ',', '.').' €', 'iban' => iban_to_human_format($value), default => $value diff --git a/app/Models/BudgetItem.php b/app/Models/BudgetItem.php index a1ae9e8c..4f020d5b 100644 --- a/app/Models/BudgetItem.php +++ b/app/Models/BudgetItem.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Enums\BudgetType; +use Cknow\Money\Casts\MoneyDecimalCast; use Database\Factories\BudgetItemFactory; use Eloquent; use Illuminate\Database\Eloquent\Builder; @@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships; /** * App\Models\BudgetItem @@ -22,12 +24,79 @@ * @method static Builder|BudgetItem query() * * @mixin Eloquent + * + * @property int $id + * @property int $budget_plan_id + * @property string|null $short_name + * @property string|null $name + * @property \Cknow\Money\Money $value + * @property \App\Models\Enums\BudgetType $budget_type + * @property bool $is_group + * @property string $description + * @property int|null $position + * @property int|null $parent_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $children + * @property-read int|null $children_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $orderedChildren + * @property-read int|null $ordered_children_count + * @property-read \App\Models\BudgetItem|null $parent + * @property-read int $depth + * @property-read string $path + * @property-read string $position_path + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $ancestors The model's recursive parents. + * @property-read int|null $ancestors_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $ancestorsAndSelf The model's recursive parents and itself. + * @property-read int|null $ancestors_and_self_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $bloodline The model's ancestors, descendants and itself. + * @property-read int|null $bloodline_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $childrenAndSelf The model's direct children and itself. + * @property-read int|null $children_and_self_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $descendants The model's recursive children. + * @property-read int|null $descendants_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $descendantsAndSelf The model's recursive children and itself. + * @property-read int|null $descendants_and_self_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $parentAndSelf The model's direct parent and itself. + * @property-read int|null $parent_and_self_count + * @property-read \App\Models\BudgetItem|null $rootAncestor The model's topmost parent. + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $siblings The parent's other children. + * @property-read int|null $siblings_count + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $siblingsAndSelf All the parent's children. + * @property-read int|null $siblings_and_self_count + * + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection all($columns = ['*']) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem breadthFirst() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem depthFirst() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem doesntHaveChildren() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection get($columns = ['*']) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem getExpressionGrammar() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem hasChildren() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem hasParent() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem isLeaf() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem isRoot() + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem tree($maxDepth = null) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem treeOf(\Illuminate\Database\Eloquent\Model|callable $constraint, $maxDepth = null) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereBudgetPlanId($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereBudgetType($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereCreatedAt($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereDepth($operator, $value = null) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereDescription($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereId($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereIsGroup($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereName($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereParentId($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem wherePosition($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereShortName($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereUpdatedAt($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem whereValue($value) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem withGlobalScopes(array $scopes) + * @method static \Staudenmeir\LaravelAdjacencyList\Eloquent\Builder|BudgetItem withRelationshipExpression($direction, callable $constraint, $initialDepth, $from = null, $maxDepth = null) */ class BudgetItem extends Model { use HasFactory; - - public $timestamps = false; + use HasRecursiveRelationships; /** * The table associated with the model. @@ -51,26 +120,44 @@ public function budgetPlan(): BelongsTo return $this->belongsTo(BudgetPlan::class, 'budget_plan_id'); } - public function parent(): BelongsTo - { - return $this->belongsTo(self::class, 'parent_id'); - } - - public function children(): HasMany - { - return $this->hasMany(self::class, 'parent_id'); - } - public function orderedChildren(): HasMany { return $this->hasMany(self::class, 'parent_id')->orderBy('position', 'asc'); } + #[\Override] protected function casts(): array { return [ 'is_group' => 'boolean', 'budget_type' => BudgetType::class, + 'value' => MoneyDecimalCast::class, + ]; + } + + /** + * Get the custom paths for the model. + * + * @see https://github.com/staudenmeir/laravel-adjacency-list#custom-paths + * Usable to sort the whole tree by position + */ + public function getCustomPaths(): array + { + return [ + [ + 'name' => 'position_path', + 'column' => 'position', + 'separator' => '.', + ], ]; } + + public function normalizeChildPositionValues(): void + { + $idx = 0; + $this->orderedChildren() + ->each(function ($child) use (&$idx): void { + $child->update(['position' => $idx++]); + }); + } } diff --git a/app/Models/BudgetPlan.php b/app/Models/BudgetPlan.php index 3bbbd272..ad6309f8 100644 --- a/app/Models/BudgetPlan.php +++ b/app/Models/BudgetPlan.php @@ -3,12 +3,15 @@ namespace App\Models; use App\Models\Enums\BudgetPlanState; +use App\Models\Enums\BudgetType; use Carbon\Carbon; use Database\Factories\BudgetPlanFactory; use Eloquent; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * App\Models\BudgetPlan @@ -32,6 +35,22 @@ * @method static Builder|BudgetPlan query() * * @mixin Eloquent + * + * @property string|null $organization + * @property int|null $fiscal_year_id + * @property-read \App\Models\FiscalYear|null $fiscalYear + * @property-read \Staudenmeir\LaravelAdjacencyList\Eloquent\Collection $rootBudgetItems + * @property-read int|null $root_budget_items_count + * + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereApprovalDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereFiscalYearId($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereOrganization($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereParentPlanId($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereResolutionDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereState($value) + * @method static \Illuminate\Database\Eloquent\Builder|BudgetPlan whereUpdatedAt($value) */ class BudgetPlan extends Model { @@ -49,6 +68,7 @@ class BudgetPlan extends Model */ protected $fillable = ['organization', 'fiscal_year_id', 'resolution_date', 'approval_date', 'state', 'parent_plan']; + #[\Override] protected function casts(): array { return [ @@ -58,13 +78,45 @@ protected function casts(): array ]; } - public function budgetItems(): \Illuminate\Database\Eloquent\Relations\HasMany + public function budgetItems(): HasMany { return $this->hasMany(BudgetItem::class); } - public function fiscalYear(): \Illuminate\Database\Eloquent\Relations\BelongsTo + public function budgetItemsTree(BudgetType $budgetType) + { + // $this is not accessible from the closure scope + $plan_id = $this->id; + + $constraint = static fn ($query) => $query->whereNull('parent_id') + ->where('budget_plan_id', $plan_id) + ->where('budget_type', $budgetType); + + // the full tree flattened out, the position path is a custom-built path + return BudgetItem::treeOf($constraint)->orderBy('position_path')->get(); + } + + public function rootBudgetItems(): Builder|HasMany|BudgetPlan + { + return $this->hasMany(BudgetItem::class)->whereNull('parent_id'); + } + + public function fiscalYear(): BelongsTo { return $this->belongsTo(FiscalYear::class); } + + /** + * Resets the position values of all children to be sequential starting from 0 + * Use in case of buggyness in the position values + */ + public function normalizePositions(): void + { + $items = $this->rootBudgetItems()->get(); + while ($items->isNotEmpty()) { + $item = $items->pop(); + $item->normalizeChildPositionValues(); + $items = $items->merge($item->children); + } + } } diff --git a/app/Models/Changelog.php b/app/Models/Changelog.php index f513563d..19d29484 100644 --- a/app/Models/Changelog.php +++ b/app/Models/Changelog.php @@ -52,6 +52,7 @@ class Changelog extends Model * * @return array */ + #[\Override] protected function casts(): array { return [ diff --git a/app/Models/Enums/BudgetPlanState.php b/app/Models/Enums/BudgetPlanState.php index 78aedfbf..5796f168 100644 --- a/app/Models/Enums/BudgetPlanState.php +++ b/app/Models/Enums/BudgetPlanState.php @@ -6,4 +6,20 @@ enum BudgetPlanState: string { case FINAL = 'final'; case DRAFT = 'draft'; + + public function slug(): string + { + return match ($this) { + self::FINAL => 'final', + self::DRAFT => 'draft', + }; + } + + public function label(): string + { + return match ($this) { + self::FINAL => __('Final'), + self::DRAFT => __('Draft'), + }; + } } diff --git a/app/Models/Enums/ChatMessageType.php b/app/Models/Enums/ChatMessageType.php new file mode 100644 index 00000000..7d2ab743 --- /dev/null +++ b/app/Models/Enums/ChatMessageType.php @@ -0,0 +1,13 @@ +belongsTo(Application::class); - } - - public function financePlanTopic(): BelongsTo - { - return $this->belongsTo(FinancePlanTopic::class); - } -} diff --git a/app/Models/FinancePlanTopic.php b/app/Models/FinancePlanTopic.php deleted file mode 100644 index 3a30f0e6..00000000 --- a/app/Models/FinancePlanTopic.php +++ /dev/null @@ -1,56 +0,0 @@ - $financePlanItems - * @property-read int|null $finance_plan_items_count - * - * @method static Builder|FinancePlanTopic whereCreatedAt($value) - * @method static Builder|FinancePlanTopic whereUpdatedAt($value) - * - * @mixin Eloquent - */ -class FinancePlanTopic extends Model -{ - use HasFactory; - - public function application(): BelongsTo - { - return $this->belongsTo(Application::class); - } - - public function financePlanItems(): HasMany - { - return $this->hasMany(FinancePlanItem::class); - } -} diff --git a/app/Models/FiscalYear.php b/app/Models/FiscalYear.php index fd6d22a7..73a57c50 100644 --- a/app/Models/FiscalYear.php +++ b/app/Models/FiscalYear.php @@ -9,8 +9,6 @@ class FiscalYear extends Model { use HasFactory; - public $timestamps = false; - /** * The table associated with the model. * @@ -21,8 +19,13 @@ class FiscalYear extends Model /** * @var array */ - protected $fillable = ['start_date', 'end_date']; + protected $fillable = [ + 'id', + 'start_date', + 'end_date', + ]; + #[\Override] protected function casts(): array { return [ diff --git a/app/Models/Legacy/BankAccount.php b/app/Models/Legacy/BankAccount.php index e1d9423c..1ababb48 100644 --- a/app/Models/Legacy/BankAccount.php +++ b/app/Models/Legacy/BankAccount.php @@ -62,6 +62,7 @@ class BankAccount extends Model */ protected $fillable = ['name', 'short', 'sync_from', 'sync_until', 'iban', 'last_sync', 'csv_import_settings', 'manually_enterable']; + #[\Override] public function casts(): array { return [ @@ -69,7 +70,7 @@ public function casts(): array ]; } - public function csvImportSettings(): Attribute + protected function csvImportSettings(): Attribute { return Attribute::make( get: static function (?string $value) { diff --git a/app/Models/Legacy/BankTransaction.php b/app/Models/Legacy/BankTransaction.php index 80ee7a43..6242bd3a 100644 --- a/app/Models/Legacy/BankTransaction.php +++ b/app/Models/Legacy/BankTransaction.php @@ -71,6 +71,7 @@ class BankTransaction extends Model */ protected $fillable = ['date', 'valuta', 'type', 'empf_iban', 'empf_bic', 'empf_name', 'primanota', 'value', 'saldo', 'zweck', 'comment', 'customer_ref']; + #[\Override] protected function casts(): array { return [ @@ -89,7 +90,7 @@ public function bookings(): HasMany return $this->hasMany(Booking::class, 'zahlung_id')->where('zahlung_type', $this->konto_id); } - public function name(): Attribute + protected function name(): Attribute { $account_name = $this->account->short; $id = $this->id; diff --git a/app/Models/Legacy/Booking.php b/app/Models/Legacy/Booking.php index 2ba2e62c..9a3300eb 100644 --- a/app/Models/Legacy/Booking.php +++ b/app/Models/Legacy/Booking.php @@ -73,12 +73,12 @@ public function user(): BelongsTo public function expensesReceiptPost(): BelongsTo { - return $this->belongsTo(ExpensesReceiptPost::class, 'beleg_id'); + return $this->belongsTo(ExpenseReceiptPost::class, 'beleg_id'); } public function expenseReceipt(): HasOneThrough { - return $this->hasOneThrough(ExpensesReceipt::class, ExpensesReceiptPost::class, 'beleg_id', 'id'); + return $this->hasOneThrough(ExpenseReceipt::class, ExpenseReceiptPost::class, 'beleg_id', 'id'); } public function expense(): BelongsTo diff --git a/app/Models/Legacy/ChatMessage.php b/app/Models/Legacy/ChatMessage.php index 0a411b33..3c1b8347 100644 --- a/app/Models/Legacy/ChatMessage.php +++ b/app/Models/Legacy/ChatMessage.php @@ -2,7 +2,11 @@ namespace App\Models\Legacy; +use App\Models\Enums\ChatMessageType; +use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * App\Models\Legacy\ChatMessage @@ -16,17 +20,17 @@ * @property string $text * @property int $type * - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage newModelQuery() - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage newQuery() - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage query() - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereCreator($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereCreatorAlias($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereId($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereTarget($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereTargetId($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereText($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereTimestamp($value) - * @method static \Illuminate\Database\Eloquent\Builder|ChatMessage whereType($value) + * @method static Builder|ChatMessage newModelQuery() + * @method static Builder|ChatMessage newQuery() + * @method static Builder|ChatMessage query() + * @method static Builder|ChatMessage whereCreator($value) + * @method static Builder|ChatMessage whereCreatorAlias($value) + * @method static Builder|ChatMessage whereId($value) + * @method static Builder|ChatMessage whereTarget($value) + * @method static Builder|ChatMessage whereTargetId($value) + * @method static Builder|ChatMessage whereText($value) + * @method static Builder|ChatMessage whereTimestamp($value) + * @method static Builder|ChatMessage whereType($value) * * @mixin \Eloquent */ @@ -44,5 +48,21 @@ class ChatMessage extends Model /** * @var array */ - protected $fillable = ['target_id', 'target', 'timestamp', 'creator', 'creator_alias', 'text', 'type']; + protected $fillable = ['target_id', 'target', 'timestamp', 'creator', 'creator_alias', 'text', 'type', + 'content', + ]; + + protected function casts(): array + { + return [ + 'timestamp' => 'datetime', + 'type' => ChatMessageType::class, + 'text' => 'encrypted', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'creator', 'username'); + } } diff --git a/app/Models/Legacy/Expense.php b/app/Models/Legacy/Expense.php new file mode 100644 index 00000000..19fc6239 --- /dev/null +++ b/app/Models/Legacy/Expense.php @@ -0,0 +1,155 @@ + $comments + * @property-read int|null $comments_count + * @property-read \Illuminate\Database\Eloquent\Collection $receipts + * @property-read int|null $receipts_count + * + * @method static Builder|Expense newModelQuery() + * @method static Builder|Expense newQuery() + * @method static Builder|Expense query() + * @method static Builder|Expense whereAddress($value) + * @method static Builder|Expense whereCreated($value) + * @method static Builder|Expense whereEtag($value) + * @method static Builder|Expense whereId($value) + * @method static Builder|Expense whereLastChange($value) + * @method static Builder|Expense whereLastChangeBy($value) + * @method static Builder|Expense whereNameSuffix($value) + * @method static Builder|Expense whereOkBelege($value) + * @method static Builder|Expense whereOkHv($value) + * @method static Builder|Expense whereOkKv($value) + * @method static Builder|Expense wherePayed($value) + * @method static Builder|Expense whereProjektId($value) + * @method static Builder|Expense whereRejected($value) + * @method static Builder|Expense whereState($value) + * @method static Builder|Expense whereVersion($value) + * @method static Builder|Expense whereZahlungIban($value) + * @method static Builder|Expense whereZahlungName($value) + * @method static Builder|Expense whereZahlungVwzk($value) + * @method HasOneOrManyThrough|ExpenseReceipt throughReceipts() + * + * @mixin \Eloquent + */ +class Expense extends Model +{ + use HasFactory; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'auslagen'; + + public $timestamps = false; + + /** + * @var array + */ + protected $fillable = ['projekt_id', 'name_suffix', 'state', 'ok_belege', 'ok_hv', 'ok_kv', 'payed', 'rejected', 'zahlung_iban', 'zahlung_name', 'zahlung_vwzk', 'address', 'last_change', 'last_change_by', 'etag', 'version', 'created']; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'projekt_id'); + } + + public function receipts(): HasMany + { + return $this->hasMany(ExpenseReceipt::class, 'auslagen_id'); + } + + public function totalIn(): Money + { + return Money::parseByDecimal($this->throughReceipts()->has('posts')->sum('einnahmen'), 'EUR'); + } + + public function totalOut(): Money + { + return Money::parseByDecimal($this->throughReceipts()->has('posts')->sum('ausgaben'), 'EUR'); + } + + protected function okKv(): Attribute + { + return Attribute::make( + get: fn (string $value) => explode(';', $value)[0], + ); + } + + protected function okHv(): Attribute + { + return Attribute::make( + get: fn (string $value) => explode(';', $value)[0], + ); + } + + protected function okBelege(): Attribute + { + return Attribute::make( + get: fn (string $value) => explode(';', $value)[0], + ); + } + + protected function payed(): Attribute + { + return Attribute::make( + get: fn (string $value) => explode(';', $value)[0], + ); + } + + protected function rejected(): Attribute + { + return Attribute::make( + get: fn (string $value) => explode(';', $value)[0], + ); + } + + protected function state(): Attribute + { + return Attribute::make( + get: fn (string $value) => explode(';', $value)[0], + ); + } + + protected function casts(): array + { + return [ + 'zahlung_iban' => 'encrypted', + 'last_change' => 'datetime', + 'state' => 'string', + ]; + } +} diff --git a/app/Models/Legacy/ExpenseReceipt.php b/app/Models/Legacy/ExpenseReceipt.php new file mode 100644 index 00000000..f98aaf6d --- /dev/null +++ b/app/Models/Legacy/ExpenseReceipt.php @@ -0,0 +1,65 @@ +hasMany(\App\Models\Legacy\ExpenseReceiptPost::class, 'beleg_id'); + } + + public function expense(): BelongsTo + { + return $this->belongsTo(Expense::class, 'auslagen_id'); + } +} diff --git a/app/Models/Legacy/ExpenseReceiptPost.php b/app/Models/Legacy/ExpenseReceiptPost.php new file mode 100644 index 00000000..2021cc15 --- /dev/null +++ b/app/Models/Legacy/ExpenseReceiptPost.php @@ -0,0 +1,60 @@ +belongsTo(ExpenseReceipt::class, 'beleg_id', 'id'); + } + + public function bookings(): HasMany + { + return $this->hasMany(Booking::class, 'beleg_id')->where('beleg_type', 'belegposten'); + } +} diff --git a/app/Models/Legacy/Expenses.php b/app/Models/Legacy/Expenses.php deleted file mode 100644 index 4fd4bc6b..00000000 --- a/app/Models/Legacy/Expenses.php +++ /dev/null @@ -1,89 +0,0 @@ - $comments - * @property-read int|null $comments_count - * @property-read \Illuminate\Database\Eloquent\Collection $receipts - * @property-read int|null $receipts_count - * - * @method static \Illuminate\Database\Eloquent\Builder|Expenses newModelQuery() - * @method static \Illuminate\Database\Eloquent\Builder|Expenses newQuery() - * @method static \Illuminate\Database\Eloquent\Builder|Expenses query() - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereAddress($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereCreated($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereEtag($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereLastChange($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereLastChangeBy($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereNameSuffix($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereOkBelege($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereOkHv($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereOkKv($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses wherePayed($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereProjektId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereRejected($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereState($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereVersion($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereZahlungIban($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereZahlungName($value) - * @method static \Illuminate\Database\Eloquent\Builder|Expenses whereZahlungVwzk($value) - * - * @mixin \Eloquent - */ -class Expenses extends Model -{ - use HasFactory; - - /** - * The table associated with the model. - * - * @var string - */ - protected $table = 'auslagen'; - - public $timestamps = false; - - /** - * @var array - */ - protected $fillable = ['projekt_id', 'name_suffix', 'state', 'ok-belege', 'ok-hv', 'ok-kv', 'payed', 'rejected', 'zahlung-iban', 'zahlung-name', 'zahlung-vwzk', 'address', 'last_change', 'last_change_by', 'etag', 'version', 'created']; - - public function project(): BelongsTo - { - return $this->belongsTo(Project::class, 'projekt_id'); - } - - public function receipts(): HasMany - { - return $this->hasMany(ExpensesReceipt::class, 'auslagen_id'); - } -} diff --git a/app/Models/Legacy/ExpensesReceipt.php b/app/Models/Legacy/ExpensesReceipt.php deleted file mode 100644 index 23e87e8e..00000000 --- a/app/Models/Legacy/ExpensesReceipt.php +++ /dev/null @@ -1,65 +0,0 @@ -hasMany(\App\Models\Legacy\ExpensesReceiptPost::class, 'beleg_id'); - } - - public function expense(): BelongsTo - { - return $this->belongsTo(Expenses::class); - } -} diff --git a/app/Models/Legacy/ExpensesReceiptPost.php b/app/Models/Legacy/ExpensesReceiptPost.php deleted file mode 100644 index 944870cc..00000000 --- a/app/Models/Legacy/ExpensesReceiptPost.php +++ /dev/null @@ -1,60 +0,0 @@ -belongsTo(ExpensesReceipt::class, 'beleg_id'); - } - - public function bookings(): HasMany - { - return $this->hasMany(Booking::class, 'beleg_id')->where('beleg_type', 'belegposten'); - } -} diff --git a/app/Models/Legacy/LegacyBudgetPlan.php b/app/Models/Legacy/LegacyBudgetPlan.php index 9929fa76..9c2e624a 100644 --- a/app/Models/Legacy/LegacyBudgetPlan.php +++ b/app/Models/Legacy/LegacyBudgetPlan.php @@ -2,7 +2,9 @@ namespace App\Models\Legacy; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough; /** * App\Models\Legacy\LegacyBudgetPlan @@ -23,6 +25,7 @@ * @method static \Illuminate\Database\Eloquent\Builder|LegacyBudgetPlan whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|LegacyBudgetPlan whereState($value) * @method static \Illuminate\Database\Eloquent\Builder|LegacyBudgetPlan whereVon($value) + * @method HasOneOrManyThrough throughBudgetGroups() * * @mixin \Eloquent */ @@ -49,6 +52,39 @@ public function budgetGroups(): \Illuminate\Database\Eloquent\Relations\HasMany public function budgetItems(): \Illuminate\Database\Eloquent\Relations\HasManyThrough { - return $this->hasManyThrough(LegacyBudgetItem::class, LegacyBudgetGroup::class); + return $this->throughBudgetGroups()->hasBudgetItems(); + } + + public static function latest(): \Eloquent|static + { + return self::orderBy('id', 'desc')->first(); + } + + public static function findByDate(?Carbon $date = null): static + { + $date ??= \Illuminate\Support\Facades\Date::now(); + + return self::query()->where('von', '<=', $date) + ->where(fn ($query) => $query->where('bis', '>=', $date) + ->orWhereNull('bis')) + ->first(); + } + + public function label(): string + { + $format = 'M y'; + if ($this->bis === null) { + return "HPP$this->id ab {$this->von->format($format)}"; + } else { + return "HHP$this->id {$this->von->format($format)} - {$this->bis->format($format)}"; + } + } + + protected function casts(): array + { + return [ + 'von' => 'date', + 'bis' => 'date', + ]; } } diff --git a/app/Models/Legacy/Project.php b/app/Models/Legacy/Project.php index f0a7c1d5..54bc9040 100644 --- a/app/Models/Legacy/Project.php +++ b/app/Models/Legacy/Project.php @@ -4,43 +4,52 @@ use App\Events\UpdatingModel; use App\Models\User; +use App\States\Project\ProjectState; +use Carbon\Carbon; +use Cknow\Money\Money; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough; +use Illuminate\Support\Collection; +use Spatie\ModelStates\HasStates; /** * App\Models\Legacy\Project * * @property int $id * @property int $creator_id - * @property string $createdat - * @property string $lastupdated + * @property Carbon $createdat + * @property Carbon $lastupdated * @property int $version - * @property string $state + * @property ProjectState $state * @property int $stateCreator_id * @property string $name - * @property string $responsible * @property string $org * @property string $org_mail * @property string $protokoll * @property string $recht * @property string $recht_additional - * @property string $date_start - * @property string $date_end + * @property Carbon $date_start + * @property Carbon $date_end * @property string $beschreibung - * @property Expenses[] $expenses + * @property Collection $expenses * @property User $user - * @property ProjectPost[] $posts + * @property-read Collection $posts + * @property-read int|null $posts_count * @property-read User $creator * @property-read int|null $expenses_count - * @property-read int|null $posts_count * @property-read User $stateCreator * * @method static Builder|Project newModelQuery() * @method static Builder|Project newQuery() * @method static Builder|Project query() + * @method static Builder|Project whereState($field, ProjectState|array $states) + * @method static Builder|Project whereNotState($field, ProjectState|array $states) * @method static Builder|Project whereBeschreibung($value) * @method static Builder|Project whereCreatedat($value) * @method static Builder|Project whereCreatorId($value) @@ -55,16 +64,19 @@ * @method static Builder|Project whereRecht($value) * @method static Builder|Project whereRechtAdditional($value) * @method static Builder|Project whereResponsible($value) - * @method static Builder|Project whereState($value) * @method static Builder|Project whereStateCreatorId($value) * @method static Builder|Project whereVersion($value) * @method static \Database\Factories\Legacy\ProjectFactory factory($count = null, $state = []) + * @method HasOneOrManyThrough throughPosts() * * @mixin \Eloquent + * + * @property string $responsible The responsible person's email (domain will be automatically appended if missing) */ class Project extends Model { use HasFactory; + use HasStates; /** * The table associated with the model. @@ -73,34 +85,131 @@ class Project extends Model */ protected $table = 'projekte'; - public $timestamps = false; + const string CREATED_AT = 'createdat'; + + const string UPDATED_AT = 'lastupdated'; /** - * @var array + * The attributes that aren't mass-assignable. + * + * @var array */ - protected $fillable = ['creator_id', 'createdat', 'lastupdated', 'version', 'state', 'stateCreator_id', 'name', 'responsible', 'org', 'org-mail', 'protokoll', 'recht', 'recht-additional', 'date-start', 'date-end', 'beschreibung']; + protected $guarded = ['id']; + + protected function responsible(): Attribute + { + return Attribute::make( + get: fn (?string $value) => empty($value) || str_contains($value, '@') ? $value : $value.'@'.config('stufis.mail_domain'), + set: fn (string $value) => empty($value) || str_contains($value, '@') ? $value : $value.'@'.config('stufis.mail_domain'), + ); + } + + public function getLegal(): array + { + return config("stufis.project_legal.$this->recht", []); + } protected $dispatchesEvents = [ 'updating' => UpdatingModel::class, ]; + protected function casts(): array + { + return [ + 'state' => ProjectState::class, + 'createdat' => 'datetime', + 'lastupdated' => 'datetime', + 'date_start' => 'date', + 'date_end' => 'date', + ]; + } + public function expenses(): HasMany { - return $this->hasMany(\App\Models\Legacy\Expenses::class, 'projekt_id'); + return $this->hasMany(Expense::class, 'projekt_id', 'id'); } public function creator(): BelongsTo { - return $this->belongsTo(\App\Models\User::class, 'creator_id'); + return $this->belongsTo(User::class, 'creator_id'); } public function stateCreator(): BelongsTo { - return $this->belongsTo(\App\Models\User::class, 'stateCreator_id'); + return $this->belongsTo(User::class, 'stateCreator_id'); + } + + public function relatedBudgetPlan(): LegacyBudgetPlan + { + return LegacyBudgetPlan::findByDate($this->createdat); } + /** + * Get the ordered posts associated with the project. + */ public function posts(): HasMany { - return $this->hasMany(ProjectPost::class, 'projekt_id'); + return $this->hasMany(ProjectPost::class, 'projekt_id')->orderBy('position'); + } + + public function expensePosts(): HasManyThrough + { + return $this->throughPosts()->hasExpensePosts(); + } + + public function attachments(): HasMany + { + return $this->hasMany(ProjectAttachment::class, 'projekt_id', 'id'); + } + + + public function totalAusgaben(): Money + { + return $this->posts()->sumMoney('ausgaben'); + } + + public function totalUsedAusgaben(): Money + { + return $this->expensePosts()->sumMoney('beleg_posten.ausgaben'); + } + + public function totalRemainingAusgaben(): Money + { + return $this->posts()->sumMoney('ausgaben')->subtract($this->totalUsedAusgaben()); + } + + public function totalRatioAusgaben(): int + { + $out = $this->totalAusgaben(); + if ($out->isZero()) { + return 0; + } + + return (int) ($this->totalUsedAusgaben()->ratioOf($out) * 100); + } + + public function totalEinnahmen(): Money + { + return $this->posts()->sumMoney('einnahmen'); + } + + public function totalUsedEinnahmen(): Money + { + return $this->expensePosts()->sumMoney('beleg_posten.einnahmen'); + } + + public function totalRemainingEinnahmen(): Money + { + return $this->posts()->sumMoney('einnahmen')->subtract($this->totalUsedEinnahmen()); + } + + public function totalRatioEinnahmen(): int + { + $in = $this->totalEinnahmen(); + if ($in->isZero()) { + return 0; + } + + return (int) ($this->totalUsedEinnahmen()->ratioOf($in) * 100); } } diff --git a/app/Models/Legacy/ProjectAttachment.php b/app/Models/Legacy/ProjectAttachment.php new file mode 100644 index 00000000..69368f6a --- /dev/null +++ b/app/Models/Legacy/ProjectAttachment.php @@ -0,0 +1,57 @@ +belongsTo(Project::class, 'projekt_id', 'id'); + } + + public function getHumanSizeAttribute() : string + { + return Attribute::make( + get: fn() => $this->attributes['size'] / 1024 . "MB", + ); + + } +} diff --git a/app/Models/Legacy/ProjectPost.php b/app/Models/Legacy/ProjectPost.php index 9694d644..28027fdf 100644 --- a/app/Models/Legacy/ProjectPost.php +++ b/app/Models/Legacy/ProjectPost.php @@ -2,8 +2,13 @@ namespace App\Models\Legacy; +use App\Events\UpdatingModel; +use Cknow\Money\Casts\MoneyDecimalCast; +use Cknow\Money\Money; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * App\Models\Legacy\ProjectPost @@ -11,23 +16,24 @@ * @property int $id * @property int $projekt_id * @property int $titel_id - * @property float $einnahmen - * @property float $ausgaben + * @property Money $einnahmen + * @property Money $ausgaben * @property string $name * @property string $bemerkung * @property Project $projekte - * @property-read \App\Models\Legacy\Project $project + * @property-read Project $project + * @property-read BudgetItem $budgetItem * - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost newModelQuery() - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost newQuery() - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost query() - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereAusgaben($value) - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereBemerkung($value) - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereEinnahmen($value) - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereId($value) - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereName($value) - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereProjektId($value) - * @method static \Illuminate\Database\Eloquent\Builder|ProjectPost whereTitelId($value) + * @method static Builder|ProjectPost newModelQuery() + * @method static Builder|ProjectPost newQuery() + * @method static Builder|ProjectPost query() + * @method static Builder|ProjectPost whereAusgaben($value) + * @method static Builder|ProjectPost whereBemerkung($value) + * @method static Builder|ProjectPost whereEinnahmen($value) + * @method static Builder|ProjectPost whereId($value) + * @method static Builder|ProjectPost whereName($value) + * @method static Builder|ProjectPost whereProjektId($value) + * @method static Builder|ProjectPost whereTitelId($value) * * @mixin \Eloquent */ @@ -42,13 +48,67 @@ class ProjectPost extends Model public $timestamps = false; + protected $dispatchesEvents = [ + 'updating' => UpdatingModel::class, + ]; + + protected $guarded = ['id', 'projekt_id']; + + protected function casts(): array + { + return [ + 'einnahmen' => MoneyDecimalCast::class, + 'ausgaben' => MoneyDecimalCast::class, + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'projekt_id', 'id'); + } + /** - * @var array + * This query is not optimal. It would be much better to join the expense receipts directly. + * To do that, we first have to untangle the composite key of the project post table. + * Laravel does not support composite keys well anyway. + * For not this stays, it should/could be changed as soon as the legacy code is removed. + * Disadvantages: no good eager loading, no good aggregation and so on. */ - protected $fillable = ['titel_id', 'einnahmen', 'ausgaben', 'name', 'bemerkung']; + public function expensePosts(): HasMany + { + return $this->hasMany(ExpenseReceiptPost::class, 'projekt_posten_id'); + /* old version before key fixing + $expenses_id = $this->project->expenses()->get('id'); + return ExpenseReceiptPost::where('projekt_posten_id', $this->id) + ->whereHas('expensesReceipt', function ($query) use ($expenses_id) { + $query->whereIn('auslagen_id', $expenses_id); + }); + */ + } - public function project(): BelongsTo + public function budgetItem(): BelongsTo + { + return $this->belongsTo(LegacyBudgetItem::class, 'titel_id'); + } + + public function expendedSum(): Money { - return $this->belongsTo(Project::class, 'projekt_id'); + if ($this->ausgaben->isZero()) { + return Money::EUR($this->expensePosts()->sum('einnahmen'), true); + } + + return Money::EUR($this->expensePosts()->sum('ausgaben'), true); + } + + public function expendedRatio(): int + { + if ($this->expensePosts()->exists() && ! $this->ausgaben->isZero()) { + return (int) ($this->expendedSum()->ratioOf($this->ausgaben) * 100); + } + if ($this->expensePosts()->exists() && ! $this->einnahmen->isZero()) { + return (int) ($this->expendedSum()->ratioOf($this->einnahmen) * 100); + } + + return 0; } } diff --git a/app/Models/Project.php b/app/Models/Project.php deleted file mode 100644 index 77156a4e..00000000 --- a/app/Models/Project.php +++ /dev/null @@ -1,70 +0,0 @@ -hasMany(Application::class); - } - - public function attachments(): HasMany - { - return $this->hasMany(ProjectAttachment::class); - } - - public function studentBodyDuties(): HasMany - { - return $this->hasMany(StudentBodyDuty::class, 'projects_to_student_body_duties'); - } - - public function formDefinition(): HasOne - { - return $this->hasOne(FormDefinition::class); - } -} diff --git a/app/Models/Actor.php b/app/Models/PtfProject/Actor.php similarity index 91% rename from app/Models/Actor.php rename to app/Models/PtfProject/Actor.php index d17a919c..0ae30be1 100644 --- a/app/Models/Actor.php +++ b/app/Models/PtfProject/Actor.php @@ -1,9 +1,10 @@ where('is_organisation', '=', true); } - public function scopeUser(Builder $builder): void + #[Scope] + protected function user(Builder $builder): void { $builder->where('is_organisation', '=', false); } diff --git a/app/Models/ActorMail.php b/app/Models/PtfProject/ActorMail.php similarity index 97% rename from app/Models/ActorMail.php rename to app/Models/PtfProject/ActorMail.php index 177590ab..8fbc616c 100644 --- a/app/Models/ActorMail.php +++ b/app/Models/PtfProject/ActorMail.php @@ -1,6 +1,6 @@ belongsToMany(Application::class); - } -} +class LegalBasis extends Model {} diff --git a/app/Models/ProjectAttachment.php b/app/Models/PtfProject/ProjectAttachment.php similarity index 79% rename from app/Models/ProjectAttachment.php rename to app/Models/PtfProject/ProjectAttachment.php index fe5a4470..3081b8fc 100644 --- a/app/Models/ProjectAttachment.php +++ b/app/Models/PtfProject/ProjectAttachment.php @@ -1,13 +1,11 @@ belongsTo(Project::class); - } -} +class ProjectAttachment extends Model {} diff --git a/app/Models/StudentBodyDuty.php b/app/Models/StudentBodyDuty.php deleted file mode 100644 index 3044e379..00000000 --- a/app/Models/StudentBodyDuty.php +++ /dev/null @@ -1,39 +0,0 @@ -belongsToMany(Project::class, 'projects_to_student_body_duties'); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index ec9ae23b..459ffa26 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -85,13 +85,12 @@ class User extends Authenticatable 'name', 'username', 'email', - 'password', 'provider', 'provider_uid', 'picture_url', 'iban', 'address', - 'version', + 'version', // last seen application version ]; /** @@ -109,6 +108,7 @@ class User extends Authenticatable * * @return array */ + #[\Override] protected function casts(): array { return [ @@ -118,11 +118,11 @@ protected function casts(): array public function getGroups(): Collection { - return app(AuthService::class)->userGroups(); + return resolve(AuthService::class)->userGroups(); } public function getCommittees(): Collection { - return app(AuthService::class)->userCommittees(); + return resolve(AuthService::class)->userCommittees(); } } diff --git a/app/Policies/AccountingPolicy.php b/app/Policies/AccountingPolicy.php new file mode 100644 index 00000000..54fc5b59 --- /dev/null +++ b/app/Policies/AccountingPolicy.php @@ -0,0 +1,36 @@ +getGroups()->contains('ref-finanzen-kv'); + } + + public function useCsvUpload(User $user, BankAccount $bankAccount): bool + { + return $user->getGroups()->contains('ref-finanzen-kv'); + } + + public function useCashJournal(User $user, BankAccount $bankAccount): bool + { + return $user->getGroups()->contains('ref-finanzen-kv'); + } + + public function viewAny(User $user): bool + { + return $user->getGroups()->contains('ref-finanzen-kv'); + } + + public function view(User $user, BankTransaction $bankTransaction): bool + { + return $user->getGroups()->contains('ref-finanzen-kv'); + } + + public function createKonto(User $user): bool + { + return $user->getGroups()->contains('ref-finanzen-kv'); + } +} diff --git a/app/Policies/BudgetPlanPolicy.php b/app/Policies/BudgetPlanPolicy.php new file mode 100644 index 00000000..f73a414f --- /dev/null +++ b/app/Policies/BudgetPlanPolicy.php @@ -0,0 +1,37 @@ +getGroups()->contains('ref-finanzen-hv'); + } + + public function update(User $user, BudgetPlan $budgetPlan): bool + { + return $user->getGroups()->contains('ref-finanzen-hv'); + } + + public function delete(User $user, BudgetPlan $budgetPlan): bool + { + return $user->getGroups()->contains('ref-finanzen-hv'); + } +} diff --git a/app/Policies/ExpensesPolicy.php b/app/Policies/ExpensesPolicy.php new file mode 100644 index 00000000..f88ad64c --- /dev/null +++ b/app/Policies/ExpensesPolicy.php @@ -0,0 +1,54 @@ +getGroups(); + $financeAll = $userGroups->contains('ref-finanzen-belege'); + $approveFinance = $userGroups->contains('ref-finanzen-hv'); + $approveOrg = $userGroups->contains('ref-finanzen-hv'); + $approveOther = $userGroups->contains('ref-finanzen-hv'); + + return match ($project->state::class) { + Draft::class => true, + Applied::class => $financeAll, + NeedOrgApproval::class => $approveOrg, + ApprovedByOrg::class => $approveOrg, + NeedFinanceApproval::class => $approveFinance, + ApprovedByFinance::class => $approveFinance, + ApprovedByOther::class => $approveOther, + Revoked::class => false, + Terminated::class => false, + + default => false, + }; + } + + public function delete(User $user, Project $Projects): bool + { + // depends on the state + return false; + } + + public function createExpense(User $user, Project $project): bool + { + return $project->state->expensable(); + } + + public function transitionTo(User $user, Project $project, ProjectState $newState): bool + { + $currentState = $project->state; + + // check if transition is possible + if (! $currentState->canTransitionTo($newState)) { + return false; + } + + $isOwner = $user->id === $project->creator->id; + $isOrg = $user->getCommittees()->contains($project->org); + $userGroups = $user->getGroups(); + + $financeAll = $userGroups->contains('ref-finanzen-belege'); + $approveFinance = $userGroups->contains('ref-finanzen-hv'); + $approveOrg = $userGroups->contains('ref-finanzen-hv'); + $approveOther = $userGroups->contains('ref-finanzen-hv'); + $terminator = $userGroups->contains('ref-finanzen-hv'); + + // there are some minor exceptions for certain states, but most of the time the needed permission is only + // defined by the new state, not the current one + return match ($newState::class) { + Draft::class => $isOwner || $isOrg || $financeAll, + Applied::class => $isOwner || $isOrg || $financeAll, + NeedOrgApproval::class => $financeAll, + ApprovedByOrg::class => $approveOrg, + NeedFinanceApproval::class => $financeAll, + ApprovedByFinance::class => $approveFinance, + ApprovedByOther::class => $approveOther, + Revoked::class => $isOwner || $isOrg || $financeAll, + Terminated::class => $isOwner || $isOrg || $terminator, + + default => false, + }; + } + + public function updateField(User $user, Project $project, string $field) + { + if ($this->update($user, $project) === false) { + return false; + } + + if ($field === 'recht' || $field === 'recht_additional' || $field === 'posten-titel') { + return match ($project->state::class) { + Draft::class => false, + default => true + }; + } + + return true; + } +} diff --git a/app/Policies/TodoPolicy.php b/app/Policies/TodoPolicy.php new file mode 100644 index 00000000..b3a0509f --- /dev/null +++ b/app/Policies/TodoPolicy.php @@ -0,0 +1,16 @@ +getGroups()->contains('ref-finanzen'); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index f78078a1..386ca0a9 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -6,6 +6,9 @@ class UserPolicy { + /** + * If admin, allow all. + */ public function before(User $user, string $ability): ?bool { return $user->getGroups()->contains('admin') ? true : null; @@ -35,4 +38,9 @@ public function admin(User $user): bool { return $user->getGroups()->contains('admin'); } + + public function seeExtendedMenu(User $user): bool + { + return $user->getGroups()->contains('ref-finanzen'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e9378124..f8aefde6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,13 +3,17 @@ namespace App\Providers; use App\Services\Auth\AuthService; +use App\Support\Money\MoneySynth; +use Cknow\Money\Money; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Livewire\Livewire; use SocialiteProviders\Manager\SocialiteWasCalled; class AppServiceProvider extends ServiceProvider @@ -17,6 +21,7 @@ class AppServiceProvider extends ServiceProvider /** * Register any application services. */ + #[\Override] public function register(): void { // @@ -45,6 +50,8 @@ public function boot(): void $this->bootRoute(); + $this->bootMoney(); + // Carbon::setLocale(config('app.locale')); } @@ -53,12 +60,14 @@ public function bootRoute() RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())); // Make sure, this vars cannot be matched with strings - // preventing missrouting + // prevents wrong routing Route::pattern('hhp_id', '[0-9]+'); Route::pattern('konto_id', '[0-9]+'); Route::pattern('titel_id', '[0-9]+'); Route::pattern('projekt_id', '[0-9]+'); Route::pattern('auslagen_id', '[0-9]+'); + Route::pattern('credential_id', '[0-9]+'); + Route::pattern('year_id', '[0-9]+'); } public function registerAuth(): void @@ -74,4 +83,10 @@ public function registerAuth(): void abort(500, 'Config Error. Wrong Auth provider given in Environment. Fitting AuthService Class not found'); }); } + + private function bootMoney(): void + { + Livewire::propertySynthesizer(MoneySynth::class); + Builder::macro('sumMoney', fn (string $column): Money => Money::EUR($this->sum($column))); + } } diff --git a/app/Rules/BicRule.php b/app/Rules/BicRule.php index 9bb78037..b11f08bd 100644 --- a/app/Rules/BicRule.php +++ b/app/Rules/BicRule.php @@ -7,5 +7,6 @@ class BicRule implements ValidationRule { + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void {} } diff --git a/app/Rules/CsvTransactionImport/BalanceColumnRule.php b/app/Rules/CsvTransactionImport/BalanceColumnRule.php index ca35ce25..6ad3f8ab 100644 --- a/app/Rules/CsvTransactionImport/BalanceColumnRule.php +++ b/app/Rules/CsvTransactionImport/BalanceColumnRule.php @@ -27,6 +27,7 @@ public function __construct(Collection $differences, Collection $balances, ?stri * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { try { diff --git a/app/Rules/CsvTransactionImport/BicColumnRule.php b/app/Rules/CsvTransactionImport/BicColumnRule.php index 5cc068c4..061dd6b7 100644 --- a/app/Rules/CsvTransactionImport/BicColumnRule.php +++ b/app/Rules/CsvTransactionImport/BicColumnRule.php @@ -18,6 +18,7 @@ public function __construct(public Collection $bics) {} * * @param Closure(string): PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { foreach ($this->bics as $bic) { diff --git a/app/Rules/CsvTransactionImport/BicRule.php b/app/Rules/CsvTransactionImport/BicRule.php index 53ab5a60..ab8b6527 100644 --- a/app/Rules/CsvTransactionImport/BicRule.php +++ b/app/Rules/CsvTransactionImport/BicRule.php @@ -15,6 +15,7 @@ public function __construct(public Collection $bics) {} * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { foreach ($this->bics as $bic) { diff --git a/app/Rules/CsvTransactionImport/DateColumnRule.php b/app/Rules/CsvTransactionImport/DateColumnRule.php index 1d12805e..cc563201 100644 --- a/app/Rules/CsvTransactionImport/DateColumnRule.php +++ b/app/Rules/CsvTransactionImport/DateColumnRule.php @@ -16,11 +16,12 @@ public function __construct(private readonly Collection $column) {} * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { try { - $firstDate = guessCarbon($this->column->first()); - $lastDate = guessCarbon($this->column->last()); + $firstDate = guessDate($this->column->first()); + $lastDate = guessDate($this->column->last()); if (! $firstDate->lessThanOrEqualTo($lastDate)) { $fail(__('konto.csv-verify-date-order-error')); } diff --git a/app/Rules/CsvTransactionImport/IbanColumnRule.php b/app/Rules/CsvTransactionImport/IbanColumnRule.php index 7a2a3e15..31ab7be7 100644 --- a/app/Rules/CsvTransactionImport/IbanColumnRule.php +++ b/app/Rules/CsvTransactionImport/IbanColumnRule.php @@ -18,6 +18,7 @@ public function __construct(public Collection $ibans) {} * * @param Closure(string): PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { foreach ($this->ibans as $iban) { diff --git a/app/Rules/CsvTransactionImport/IbanRule.php b/app/Rules/CsvTransactionImport/IbanRule.php index 6f70f6b5..0f906532 100644 --- a/app/Rules/CsvTransactionImport/IbanRule.php +++ b/app/Rules/CsvTransactionImport/IbanRule.php @@ -15,6 +15,7 @@ public function __construct(public Collection $ibans) {} * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { foreach ($this->ibans as $iban) { diff --git a/app/Rules/CsvTransactionImport/MoneyColumnRule.php b/app/Rules/CsvTransactionImport/MoneyColumnRule.php index 4f76b09a..984c9b62 100644 --- a/app/Rules/CsvTransactionImport/MoneyColumnRule.php +++ b/app/Rules/CsvTransactionImport/MoneyColumnRule.php @@ -15,6 +15,7 @@ public function __construct(private readonly Collection $column) {} * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/app/Rules/ExactlyOneZeroMoneyRule.php b/app/Rules/ExactlyOneZeroMoneyRule.php new file mode 100644 index 00000000..63f13cc1 --- /dev/null +++ b/app/Rules/ExactlyOneZeroMoneyRule.php @@ -0,0 +1,43 @@ +data = $data; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + // pairs every attribute field with the other field, pair[0] has the actual field, pair[1] can have *'s + $otherAccessors = Str::of($attribute)->explode('.') + ->zip(Str::of($this->otherField)->explode('.')) + ->map(fn (Collection $pair) => $pair[1] === '*' ? $pair[0] : $pair[1]); + // dump($otherAccessors); + $otherMoney = $this->data; + while (($idx = $otherAccessors->shift()) !== null) { + // dump($otherMoney, $idx, $otherAccessors); + $otherMoney = $otherMoney[$idx]; + } + // dd($otherMoney); + $oneIsZero = (($value->getAmount() === '0') xor ($otherMoney->getAmount() === '0')); + // dd($value, $otherMoney, ($value->getAmount() === "0"),($otherMoney->getAmount() === "0"), $oneIsZero); + if (! $oneIsZero) { + $fail(__('errors.one-money-has-to-be-zero')); + } + } +} diff --git a/app/Rules/FluxEditorRule.php b/app/Rules/FluxEditorRule.php new file mode 100644 index 00000000..39079048 --- /dev/null +++ b/app/Rules/FluxEditorRule.php @@ -0,0 +1,23 @@ +
- - @endif - @if($orphaned_plans->isNotEmpty()) -
- -
- @endif + @endforeach + @if($orphaned_plans->isNotEmpty()) + + Pläne ohhneee HHHHJ + + @endif + @foreach($orphaned_plans as $plan) + + + {{ __('budget-plan.plan?') }} {{ $plan->id }} + + + + {{ $plan->state }} + + + + + + + + + + + + @endforeach + + diff --git a/resources/views/budget-plan/show.blade.php b/resources/views/budget-plan/show.blade.php deleted file mode 100644 index 7805caa2..00000000 --- a/resources/views/budget-plan/show.blade.php +++ /dev/null @@ -1,4 +0,0 @@ - - BudgetPlan.show -> - Edit - diff --git a/resources/views/budget-plan/view.blade.php b/resources/views/budget-plan/view.blade.php new file mode 100644 index 00000000..80ea9909 --- /dev/null +++ b/resources/views/budget-plan/view.blade.php @@ -0,0 +1,160 @@ +@php + use App\Models\Enums\BudgetType; + use Cknow\Money\Money; +@endphp + + +
+ + {{ __('budget-plan.view.headline') }} + + {{ $plan->organization ?? __('budget-plan.view.no-organization') }} + @if($plan->fiscalYear) + · {{ __('budget-plan.fiscal-year') }}: {{ $plan->fiscalYear->start_date->format('d.m.Y') }} + - {{ $plan->fiscalYear->end_date->format('d.m.Y') }} + @endif + + + + {{ __('budget-plan.view.actions') }} + + {{ __('budget-plan.view.edit') }} + {{ __('budget-plan.view.duplicate') }} + {{ __('budget-plan.view.print') }} + {{ __('budget-plan.view.export') }} + + + + + +
+
+
+
Status
+
+
+ 71,897 + from 70,946 +
+ +
+ + Increased by + 12% +
+
+
+
+
Avg. Open Rate
+
+
+ 58.16% + from 56.14% +
+ +
+ + Increased by + 2.02% +
+
+
+
+
Avg. Click Rate
+
+
+ 24.57% + from 28.62% +
+ +
+ + Decreased by + 4.05% +
+
+
+
+
+ + {{-- Budgetplan table --}} + + + + {{ __('budget-plan.edit.tab-headline.in') }} + + + {{ __('budget-plan.edit.tab-headline.out') }} + + + + @foreach(BudgetType::cases() as $budgetType) + +
+
+
+
+
+ + + + + + + + + + + + + @foreach($items[$budgetType->slug()] as $item) + + @endforeach + +
+ Title + + Name + + {{-- Sigma column --}} + + Soll + + Ist + + B +
+
+
+
+
+
+ {{-- +
+
{{ __('budget-plan.budget-shortname') }}
+
{{ __('budget-plan.budget-longname') }}
+
{{ __('budget-plan.budget-value') }}
+
{{ __('budget-plan.view.booked') }}
+
{{ __('budget-plan.view.available') }}
+ + @foreach($items[$budgetType->slug()] as $budgetItem) + + @endforeach + +
+ --}} +
+ @endforeach +
+ +
+
diff --git a/resources/views/components/item-group.blade.php b/resources/views/components/budgetplan/item-group-edit.blade.php similarity index 54% rename from resources/views/components/item-group.blade.php rename to resources/views/components/budgetplan/item-group-edit.blade.php index e87b7c4a..8be71322 100644 --- a/resources/views/components/item-group.blade.php +++ b/resources/views/components/budgetplan/item-group-edit.blade.php @@ -1,59 +1,66 @@ @props([ 'level' => 0, 'item', + /* @var bool array of booleans, one for each level, indicating if the item is the last one on that level */ + 'lastItem' => [], ])
$level === 1, - //'col-start-3' => $level === 2, - //'col-start-4' => $level === 3, - //'ml-5' => $level === 1, - //'ml-10' => $level === 2, - //'ml-16' => $level === 3, - //"border-zinc-300 ", - //"border-l-1" => $level === 0 && $item->is_group, - //"border-l-2" => $level === 1 && $item->is_group, - //"border-l-3" => $level === 2 && $item->is_group, - //"border-l-4" => $level === 3 && $item->is_group, - + "col-span-8 grid grid-cols-subgrid", ]) x-sort:item="{{ $item->id }}">
$item->is_group, - "rounded" => $item->is_group, - "bg-zinc-300 my-2" => $item->is_group, + //"py-2" => $item->is_group, + //"rounded" => $item->is_group, + //"bg-zinc-300 my-2" => $item->is_group, ])>
@if($item->is_group) - + @else - + @endif
-
+
-
+
-
+
+ @if($level > 0) +
+ @for($i = 1; $i <= $level; $i++) + +
$i < $level, + "h-full border-l-2 border-gray-300" => !($lastItem[$i-1]), + "h-1/2 border-l-2 border-gray-300" => ($lastItem[$i-1]) && $i === $level, + ])>
+ @endfor + +
+
+ @endif $level === 3, - //'pl-5' => $level === 2, + "my-2" + //'pl-16 border-l-8 border-zinc-300' => $level === 3, + //'pl-10 border-l-6 border-zinc-300' => $level === 2, + //'pl-5 border-l-4 border-zinc-300' => $level === 1, ])> @if($item->is_group) - + Σ @endif - - +
-
{{-- Action Buttons --}} +
{{-- Action Buttons --}} @if($item->is_group) {{-- subtle or ghost --}} @@ -80,16 +87,20 @@
@if($item->is_group)
$level === 0 && $item->is_group, - "border-l-16" => $level === 1 && $item->is_group, - "border-l-24" => $level === 2 && $item->is_group, - "border-l-28" => $level === 3 && $item->is_group, - + "col-span-8 grid grid-cols-subgrid", + //"border-zinc-300 ", + //"border-l-12" => $level === 0, + //"border-l-16" => $level === 1, + //"border-l-24" => $level === 2, + //"border-l-28" => $level === 3, ]) x-sort="$wire.sort($item,$position)"> @foreach($item->orderedChildren as $child) - + @endforeach
@endif diff --git a/resources/views/components/budgetplan/item-group-view.blade.php b/resources/views/components/budgetplan/item-group-view.blade.php new file mode 100644 index 00000000..b7a15651 --- /dev/null +++ b/resources/views/components/budgetplan/item-group-view.blade.php @@ -0,0 +1,112 @@ +@props([ + 'level' => 0, + 'item', + 'lastItem' => [], +]) + +{{-- children inside --}} +
$level === 0, + //"bg-gray-300" => $level === 1, + //"bg-gray-200" => $level === 2, + //"bg-gray-100" => $level === 3, +])> + {{-- only this row --}} +
+ {{-- Hieracy column --}} +
+
+ @for($i = 1; $i <= $level; $i++) + +
$i < $level, + 'h-full border-l-2 border-zinc-300 dark:border-zinc-600' => !($lastItem[$i-1] ?? false), + 'h-1/2 border-l-2 border-zinc-300 dark:border-zinc-600' => ($lastItem[$i-1] ?? false) && $i === $level, + ])>
+ @endfor + + @if($level > 0) +
+ @endif + + @if($item->is_group) + $level === 0, + 'fill-zinc-500 dark:fill-zinc-500' => $level > 0, + ])/> + @else + + @endif +
+
+ + {{-- Icon and short title column --}} +
+ + {{-- Short Name Column --}} + $item->is_group, + 'text-zinc-700 dark:text-zinc-300' => !$item->is_group, + ])> + {{ $item->short_name }} + +
+ + {{-- Long Name Column --}} +
+ $item->is_group, + 'text-zinc-700 dark:text-zinc-300' => !$item->is_group, + ])> + {{ $item->name }} + +
+ + + + {{-- Value Column --}} +
+ @if($item->is_group) + + {{ $item->value->format() }} + + @else + + {{ $item->value->format() }} + + @endif +
+ + {{-- Booked Column --}} +
+ + {{-- TODO: Implement booked amount calculation --}} + - + +
+ + {{-- Available Column --}} +
+ + {{-- TODO: Implement available amount calculation --}} + - + +
+ + {{-- Empty column for alignment --}} +
+
+ + {{-- Recursively render children with hierarchy tracking --}} + @if($item->is_group && $item->orderedChildren->isNotEmpty()) + @foreach($item->orderedChildren as $child) + + @endforeach + @endif +
diff --git a/resources/views/components/budgetplan/view-row.blade.php b/resources/views/components/budgetplan/view-row.blade.php new file mode 100644 index 00000000..46c11ef0 --- /dev/null +++ b/resources/views/components/budgetplan/view-row.blade.php @@ -0,0 +1,125 @@ +@php use App\Models\BudgetItem; @endphp +@props([ + 'item', + 'level' => $item->depth, +]) + +@php /** @var $item BudgetItem */ @endphp + +class([ + "odd:bg-gray-50", + "border-t border-gray-200", + "border-x-4 border-x-indigo-600" => $level === 0, + "border-x-4 border-x-indigo-400" => $level === 1, + "border-x-4 border-x-indigo-200" => $level === 2, + "border-x-4 border-x-indigo-50 " => $level === 3, + "text-sm font-medium text-gray-900" => $item->is_group, + "text-sm whitespace-nowrap text-gray-700" => !$item->is_group, +])}}> + @if($item->is_group) + {{-- Is Group ; th needed to make sticky work --}} + $level === 0, + "px-3 sm:pl-8" => $level === 1, + "px-3 sm:pl-13" => $level === 2, + "px-3 sm:pl-18" => $level === 3, + ])> + + {{ $item->short_name }} + + {{ $item->name }} + + @if($item->is_group) Σ @endif + + {{ $item->value }} + {{ $item->value }} + {{ $item->value }} + + @else + {{-- No Group --}} + $level === 0, + "px-3 sm:pl-8" => $level === 1, + "px-3 sm:pl-13" => $level === 2, + "px-3 sm:pl-18" => $level === 3, + ])> + + {{ $item->short_name }} + + {{ $item->name }} + + {{ $item->value }} + {{ $item->value }} + {{ $item->value }} + @endif + + + + + diff --git a/resources/views/components/file-card.blade.php b/resources/views/components/file-card.blade.php new file mode 100644 index 00000000..918ec152 --- /dev/null +++ b/resources/views/components/file-card.blade.php @@ -0,0 +1,97 @@ +@blaze + +@props([ + 'icon' => 'document', + 'invalid' => false, + 'actions' => null, + 'heading' => null, + 'inline' => false, + 'image' => null, + 'text' => null, + 'size' => null, + 'href' => null, +]) + +@php + $classes = Flux::classes() + ->add('overflow-hidden') // Overflow hidden is here to prevent the button from growing when selected text is too long. + ->add('flex items-start') + ->add('shadow-xs') + ->add('bg-white hover:bg-gray-100 dark:bg-white/10 dark:disabled:bg-white/[7%]') + // Make the placeholder match the text color of standard input placeholders... + ->add('disabled:shadow-none') + ->add('min-h-10 text-base sm:text-sm rounded-lg block w-full') + ->add($invalid + ? 'border border-red-500' + : 'border border-zinc-200 border-b-zinc-300/80 dark:border-white/10' + ) + ; + + $figureWrapperClasses = Flux::classes() + ->add('p-[calc(0.75rem-1px)] flex items-baseline') + ->add('[&:has([data-slot=image])]:p-[calc(0.5rem-1px)]') + ; + + $imageWrapperClasses = Flux::classes() + ->add('relative mr-1 size-11 rounded-sm overflow-hidden') + ->add([ + 'after:absolute after:inset-0 after:inset-ring-[1px] after:inset-ring-black/7 dark:after:inset-ring-white/10', + 'after:rounded-sm', + ]) + ; + + if ($size) { + if ($size < 1024) { + $text = round($size) . ' B'; + } elseif ($size < 1024 * 1024) { + $text = round($size / 1024) . ' KB'; + } elseif ($size < 1024 * 1024 * 1024) { + $text = round($size / 1024 / 1024) . ' MB'; + } else { + $text = round($size / 1024 / 1024 / 1024) . ' GB'; + } + } + + $iconVariant = $text ? 'solid' : 'micro'; +@endphp +
class($classes) }}> + +
+ @if(str_contains($icon, 'pdf' )) + + @elseif(str_contains($icon, 'xls') || str_contains($icon, 'opendocument.spreadsheet')) + + @else + + @endif + + +
+ +
+ +
+ +
+ +
{{ $heading }}
+ + + +
{{ $text }}
+ +
+
+ + +
attributes->class([ + 'p-[calc(0.25rem-1px)]', + 'flex-shrink-0 self-start flex h-full items-center gap-2' + ]) }} data-slot="actions"> + {{ $actions }} +
+ +
diff --git a/resources/views/components/headline.blade.php b/resources/views/components/headline.blade.php deleted file mode 100644 index 5eda2700..00000000 --- a/resources/views/components/headline.blade.php +++ /dev/null @@ -1,19 +0,0 @@ -@props([ - 'level' => 'h1', // TODO: different styles? - 'headline', - 'subText', -]) - -
-
-

{{ $headline }}

-

{{ $subText }}

-
-
- @isset($button) - - {{ $button }} - - @endisset -
-
diff --git a/resources/views/components/intro.blade.php b/resources/views/components/intro.blade.php new file mode 100644 index 00000000..64331df2 --- /dev/null +++ b/resources/views/components/intro.blade.php @@ -0,0 +1,24 @@ +@props([ + 'headline' => $slot, // backwards compatible + 'subHeadline' => '', // used as attribute string + 'button', // used as string text or a slot + 'level' => 1 +]) + +
+
+ {{ $headline }} + @if($subHeadline) + {{ $subHeadline }} + @endif +
+ @isset($button) +
+ @if(is_string($button)) + {{ $button }} + @else + {{ $button }} + @endif +
+ @endisset +
diff --git a/resources/views/components/layouts/index.blade.php b/resources/views/components/layouts/index.blade.php index 2ce12c83..855c44be 100644 --- a/resources/views/components/layouts/index.blade.php +++ b/resources/views/components/layouts/index.blade.php @@ -18,7 +18,7 @@ - {{ $title ?? 'StuRa Finanzen' }} + {{ $title ?? 'StuFiS Finanzen' }} @@ -35,7 +35,7 @@
{{ config('app.name') }}
- @@ -195,12 +195,9 @@ class="absolute top-1 right-0 -mr-14 p-1"> -
-
- - - Neues Projekt - +
+
-
- - +
+ + {{ __('general.new-project-button') }} + + + {{ __('general.new-project-button') }} + +
+
+ +
diff --git a/resources/views/components/money-input.blade.php b/resources/views/components/money-input.blade.php index 710832f6..e85e88ee 100644 --- a/resources/views/components/money-input.blade.php +++ b/resources/views/components/money-input.blade.php @@ -1,15 +1,10 @@ @props([ - 'model', - 'value' => 0, + 'disabled' => false, ]) -whereDoesntStartWith('wire:model') }} -/> - - - - - +@if(!$disabled) + merge(['class:input' => 'text-right']) }} /> + +@else + merge(['class:input' => 'text-right text-black!']) }}/> +@endif diff --git a/resources/views/components/no-content.blade.php b/resources/views/components/no-content.blade.php new file mode 100644 index 00000000..e6241231 --- /dev/null +++ b/resources/views/components/no-content.blade.php @@ -0,0 +1 @@ +

{{ __('project.view.details.none') }}

diff --git a/resources/views/components/profile-pic.blade.php b/resources/views/components/profile-pic.blade.php index c4346f20..7a67d229 100644 --- a/resources/views/components/profile-pic.blade.php +++ b/resources/views/components/profile-pic.blade.php @@ -1,7 +1,8 @@ @props([ 'user' => Auth::user(), + 'size' => "md" ]) - + diff --git a/resources/views/flux/table/row-headline.blade.php b/resources/views/flux/table/row-headline.blade.php new file mode 100644 index 00000000..a3efc1fe --- /dev/null +++ b/resources/views/flux/table/row-headline.blade.php @@ -0,0 +1,11 @@ +@props([ + 'key' => null, +]) + +merge(['class' => 'bg-zinc-200']) }} data-flux-row> + +
+ {{ $slot }} +
+ + diff --git a/resources/views/livewire/bank/csv-import.blade.php b/resources/views/livewire/bank/csv-import.blade.php index cb1321e8..766d2774 100644 --- a/resources/views/livewire/bank/csv-import.blade.php +++ b/resources/views/livewire/bank/csv-import.blade.php @@ -1,5 +1,5 @@
- +
@@ -43,10 +43,10 @@
@if($account_id !== "")
- +

{{ __('konto.csv-draganddrop-fat-text') }}

-

{{ __('konto.csv-draganddrop-sub-text') }}

+

{{ __('konto.csv-draganddrop-sub-headline') }}

@endif @@ -54,7 +54,7 @@ @if(isset($csv) && !$errors->has('csv'))
- +
{{ __('konto.manual-button-reverse-csv-order') }} diff --git a/resources/views/livewire/budgetplan/plan-edit.blade.php b/resources/views/livewire/budget-plan/plan-edit.blade.php similarity index 81% rename from resources/views/livewire/budgetplan/plan-edit.blade.php rename to resources/views/livewire/budget-plan/plan-edit.blade.php index 910a13db..e9cd3746 100644 --- a/resources/views/livewire/budgetplan/plan-edit.blade.php +++ b/resources/views/livewire/budget-plan/plan-edit.blade.php @@ -1,13 +1,4 @@
- - - Haushaltspläne - 2025 - Edit - - - -
{{ __('budget-plan.edit.headline') }} {{ __('budget-plan.edit.sub') }} @@ -16,7 +7,7 @@
- + None @foreach($fiscal_years as $fiscal_year) {{ $fiscal_year->start_date->format('d.m.y') }} - {{ $fiscal_year->end_date->format('d.m.y') }} @@ -43,7 +34,7 @@
- {{ __('budget-plan.edit.table.headline.shortname') }} + {{ __('budget-plan.budget-shortname') }} @@ -52,7 +43,7 @@
- {{ __('budget-plan.edit.table.headline.name') }} + {{ __('budget-plan.budget-longname') }} @@ -60,10 +51,10 @@
-
- {{ __('budget-plan.edit.table.headline.value') }} +
+ {{ __('budget-plan.budget-value') }} - + {{ __('budget-plan.edit.table.headline.value-hint') }} @@ -73,7 +64,7 @@
@foreach($root_items[$budgetType->slug()] as $id) - + @endforeach
@@ -86,6 +77,9 @@
{{ __('budget-plan.edit.save') }} + + DEV: Reset Positions + Last saved yesterday diff --git a/resources/views/livewire/chat-panel.blade.php b/resources/views/livewire/chat-panel.blade.php new file mode 100644 index 00000000..141ccffb --- /dev/null +++ b/resources/views/livewire/chat-panel.blade.php @@ -0,0 +1,68 @@ +@php use App\Models\Enums\ChatMessageType; @endphp +
+

Nachrichten

+ +
    + @foreach($messages as $message) + @if($message->type === ChatMessageType::SYSTEM) +
  • +
    +
    +
    +
    +
    +
    +

    + Statuswechsel {{ $message->text }}

    + +
  • + @elseif($message->type === ChatMessageType::PUBLIC) +
  • +
    +
    +
    + +
    + +
    +
    +
    +
    {{ $message->user->name ?? $message->creator_alias ?? "Unknown" }} commented +
    + +
    +

    {!! $message->text !!}

    +
    +
  • + @elseif($message->type === ChatMessageType::SUPPORT) + @elseif($message->type === ChatMessageType::FINANCE) + @endif + @endforeach + + +
+ + +
+
+ +
+
+
+ + +
+
+ +
+
+
+ @error('content') +
{{ $message }}
+ @enderror + + +
diff --git a/resources/views/livewire/fiscal-year/edit-fiscal-year.blade.php b/resources/views/livewire/fiscal-year/edit-fiscal-year.blade.php new file mode 100644 index 00000000..85e02bfd --- /dev/null +++ b/resources/views/livewire/fiscal-year/edit-fiscal-year.blade.php @@ -0,0 +1,18 @@ +
+ + Test + + Lorem ipsum + + +
+ + +
+
+ + Speichern + +
+ +
diff --git a/resources/views/livewire/project-overview.blade.php b/resources/views/livewire/project-overview.blade.php new file mode 100644 index 00000000..ce292b30 --- /dev/null +++ b/resources/views/livewire/project-overview.blade.php @@ -0,0 +1,288 @@ +
+ {{-- Header with Budget Plan Selector --}} +
+
+ {{ __('dashboard.page_titles.projects') }} + + {{ $this->currentBudgetPlan?->label() }} + +
+ + @foreach($this->budgetPlans as $plan) + + {{ $plan->label() }} + @if($plan->state) + ({{ $plan->state }}) + @endif + + @endforeach + +
+ + {{-- Tabs --}} + + + {{ __('dashboard.tabs.my_committees') }} + + + {{ __('dashboard.tabs.all_committees') }} + + + {{ __('dashboard.tabs.open_projects') }} + + + + {{-- Content --}} + @if($tab === 'mygremium' && empty($this->userCommittees)) + + {{ __('dashboard.alerts.no_committee_title') }} + {{ __('dashboard.alerts.no_committee_message') }} + + @elseif(empty($this->projectsByCommittee)) + @if($tab === 'open-projects') + + {{ __('dashboard.alerts.no_open_projects_title') }} + {{ __('dashboard.alerts.no_open_projects_message') }} + + @else + + {{ __('dashboard.alerts.no_projects_title') }} + {{ __('dashboard.alerts.no_projects_message') }} + + + {{ __('dashboard.alerts.create_new_project') }} + + + + @endif + @else +
+ @foreach($this->projectsByCommittee as $committee => $projects) + @if(count($projects) > 0) +
+ {{-- Committee Header --}} +
+ + {{ $committee ?: __('dashboard.unassigned_projects') }} + + {{ count($projects) }} +
+ + {{-- Projects Grid --}} +
+ @foreach($projects as $project) + @php + $year = $project['createdat']->format('y'); + $projectId = $project['id']; + $expenses = $this->expensesByProjectId[$projectId] ?? []; + $hasExpenses = count($expenses) > 0; + $stateLabel = is_object($project['state']) ? $project['state']->label() : $project['state']; + @endphp + +
+ {{-- Project Header Row --}} +
+ {{-- Project ID & Name --}} +
+
+ + IP-{{ $year }}-{{ $projectId }} + + {{ $stateLabel }} +
+

+ {{ $project['name'] }} +

+
+ + {{-- Budget Info --}} + + + {{-- Expand Button (only if has expenses) --}} + @if($hasExpenses) + + @else +
+ @endif +
+ + {{-- Mobile Budget Info --}} +
+
+ {{ __('dashboard.table.expenses') }}: + {{ number_format($project['total_ausgaben'] ?? 0, 2, ',', '.') }} € +
+
+ {{ __('dashboard.table.income') }}: + {{ number_format($project['total_einnahmen'] ?? 0, 2, ',', '.') }} € +
+
+ + {{-- Expenses Panel (Expandable) --}} + @if($hasExpenses) +
+
+ {{ __('dashboard.expenses_heading') }} + + {{-- Expenses Table --}} +
+ + + {{ __('dashboard.table.name') }} + + {{ __('dashboard.table.income') }} + {{ __('dashboard.table.expenses') }} + {{ __('dashboard.table.status') }} + + + @php + $sumSubmittedIn = 0; + $sumSubmittedOut = 0; + $sumPaidIn = 0; + $sumPaidOut = 0; + @endphp + @foreach($expenses as $expense) + @php + $expenseIn = collect($expense['receipts'] ?? [])->flatMap(fn($r) => $r['posts'] ?? [])->sum('einnahmen'); + $expenseOut = collect($expense['receipts'] ?? [])->flatMap(fn($r) => $r['posts'] ?? [])->sum('ausgaben'); + + $state = $expense['state']; + if (str_starts_with($state, 'booked') || str_starts_with($state, 'instructed')) { + $sumPaidIn += $expenseIn; + $sumPaidOut += $expenseOut; + } + if (!str_starts_with($state, 'revocation') && !str_starts_with($state, 'draft')) { + $sumSubmittedIn += $expenseIn; + $sumSubmittedOut += $expenseOut; + } + + $stateKey = explode(';', $state)[0]; + $stateColor = match($stateKey) { + 'draft' => 'zinc', + 'wip' => 'amber', + 'ok' => 'green', + 'instructed' => 'sky', + 'booked' => 'emerald', + 'revocation' => 'red', + default => 'zinc' + }; + @endphp + + + + A{{ $expense['id'] }} + @if($expense['name_suffix']) + - {{ $expense['name_suffix'] }} + @endif + + + + + {{ number_format($expenseIn, 2, ',', '.') }} € + + + {{ number_format($expenseOut, 2, ',', '.') }} € + + + + {{ __('dashboard.expense_states.' . $stateKey) }} + + + + @endforeach + + +
+ + {{-- Summary Cards --}} +
+
+
+ {{ __('dashboard.summary.submitted') }} +
+
+
+ + {{ number_format($sumSubmittedIn, 2, ',', '.') }} € +
+
+ + {{ number_format($sumSubmittedOut, 2, ',', '.') }} € +
+
+ Δ + + {{ number_format($sumSubmittedOut - $sumSubmittedIn, 2, ',', '.') }} € + +
+
+
+
+
+ {{ __('dashboard.summary.paid') }} +
+
+
+ + {{ number_format($sumPaidIn, 2, ',', '.') }} € +
+
+ + {{ number_format($sumPaidOut, 2, ',', '.') }} € +
+
+ Δ + + {{ number_format($sumPaidOut - $sumPaidIn, 2, ',', '.') }} € + +
+
+
+
+
+
+ @endif +
+ @endforeach +
+
+ @endif + @endforeach +
+ @endif +
diff --git a/resources/views/livewire/project/edit-project.blade.php b/resources/views/livewire/project/edit-project.blade.php new file mode 100644 index 00000000..73168569 --- /dev/null +++ b/resources/views/livewire/project/edit-project.blade.php @@ -0,0 +1,378 @@ +
+ + {{-- Header --}} +
+
+

+ {{ $isNew ? 'Neues Projekt anlegen' : 'Projekt bearbeiten' }} +

+ @if (!$isNew) +

+ Projekt #{{ $project_id }} - {{ $state->label() }} +

+ @else +

+ Neues Projekt +

+ @endif +
+ + {{-- Form Actions --}} +
+
+
+ Zurück + + Speichern als {{ $this->getState()->label() }} + +
+ @error('save') +

{{ $message }}

+ @enderror +
+
+
+ + {{-- Approval Section (only visible for non-draft projects) --}} + @if($state_name !== 'draft') +
+
+
+

Genehmigung

+ + + + + + {{ __('project.view.approval.info_toggle') }} + + +
+ +
+ {{-- Rechtsgrundlage Dropdown --}} + + @foreach ($rechtsgrundlagen as $rg) + {{ $rg['label'] }} + @endforeach + + + {{-- Dynamic Additional Fields per Rechtsgrundlage --}} +
+ @isset ($rechtsgrundlagen[$recht]['has_additional']) + + @endisset +
+
+ @if (isset($rechtsgrundlagen[$recht]['hint'])) +

{{ $rechtsgrundlagen[$recht]['hint'] }}

+ @endisset +
+
+
+
+ @endif + + {{-- Main Project Information --}} +
+
+
+

Projektinformationen

+ + + + + + {{ __('project.view.details.info_toggle') }} + + +
+ +
+ {{-- Project Name --}} +
+ +
+ + {{-- Responsible Person --}} +
+ + Projektveratantwortlich (E-Mail) + + + {{-- @domain.com --}} + + +
+ + {{-- Organization --}} +
+ + @foreach ($gremien as $label) + {{ $label }} + @endforeach + +
+ + {{-- Organization Mail --}} + @if (false) +
+ + @foreach($mailingLists as $mailingLists) + {{ $mailingLists }} + @endforeach + +
+ @endif + + {{-- Project Duration --}} +
+ + + + + +
+ + {{-- Protocol Link (optional based on config) --}} + @if (!in_array('hide-protokoll', config('stufis.project.show-link', []))) +
+ +
+ @endif + + {{-- Creation Date --}} +
+ + @foreach ($budgetPlans as $plan) + {{ $plan->label() }} + @endforeach + +
+
+
+
+ + {{-- Project Posts (Budget Items) Table --}} +
+
+
+

Budget-Posten

+ + + + + + {{ __('project.view.budget_table.info_toggle') }} + + +
+ +
+ + + + + + + @if (true) + + @endif + + + + + + + @foreach ($posts as $index => $post) + + {{-- Row Number --}} + + + {{-- Post Name --}} + + + {{-- Remarks --}} + + + {{-- Budget Title --}} + @if (true) + + @endif + + {{-- Income --}} + + + {{-- Expenses --}} + + + {{-- Actions --}} + + + @endforeach + + + + + + + + + + + +
+ Nr. + + Ein/Ausgabengruppe + + Bemerkung + + Titel + + Einnahmen + + Ausgaben + + Aktionen +
+ {{ $loop->iteration }}. + + + + + + + + @if (true) + + @foreach ($budgetTitles as $title) + + {{ $title->titel_name }} + ({{ $title->titel_nr }}) + + @endforeach + + + @else + - + @endif + + + + + + @if($this->isPostDeletable($index)) + + + + @endif +
+ Posten hinzufügen + + Summen: + +
+ {{ $this->getTotalIncome() }} +
+
+
+ {{ $this->getTotalExpenses() }} +
+
+
+
+
+ + {{-- Project Description --}} +
+
+
+

Projektbeschreibung

+ + + + + + {{ __('project.view.description.info_toggle') }} + + +
+
+ +
+
+
+ +
+
+ + + + + +
+
+ @foreach($newAttachments as $attachment) + + + + + + @endforeach + @foreach($existingAttachments as $attachment) + + + + + + @endforeach +
+
+
+
+ + {{-- Form Actions --}} +
+
+
+ Zurück + + Speichern als {{ $this->getState()->label() }} + + @if($this->getState()->equals(\App\States\Project\Draft::class)) + + Speichern als beantragt + + @endif +
+ @error('save') +

{{ $message }}

+ @enderror +
+
+
diff --git a/resources/views/livewire/project/show-project.blade.php b/resources/views/livewire/project/show-project.blade.php new file mode 100644 index 00000000..bb202b01 --- /dev/null +++ b/resources/views/livewire/project/show-project.blade.php @@ -0,0 +1,624 @@ +@php use Cknow\Money\Money; @endphp + +@php $totalAusgaben = $project->totalAusgaben() @endphp +@php $totalRemainingAusgaben = $project->totalRemainingAusgaben() @endphp +@php $totalRatioAusgaben = $project->totalRatioAusgaben(); @endphp + +@php $totalEinnahmen = $project->totalEinnahmen() @endphp +@php $totalRemainingEinnahmen = $project->totalRemainingEinnahmen() @endphp +@php $totalRatioEinnahmen = $project->totalRatioEinnahmen(); @endphp + +
+
+ + +
+
+
+

{{ __('project.view.header.title') }} {{ $project->id }}

+

{{ __('project.view.header.created_at') }} {{ $project->createdat?->format('d.m.Y') }}

+
+ +
+ {{-- Button Row --}} + + + {{ __('project.view.header.change_status') }} + + + @can('update', $project) + + {{ __('project.view.header.edit') }} + + @else + +
+ {{ __('project.view.header.edit') }} +
+
+ @endcan + @can('create-expense', $project) + + {{ __('project.view.header.new-expense') }} + + @else + +
+ {{ __('project.view.header.new-expense') }} +
+
+ @endcan + + + + + + + {{ __('project.view.header.old-view') }} + + + {{ __('project.view.header.delete') }} + + + +
+
+
+ + +
+ +
+
+
+

{{ __('project.view.summary_cards.state') }}

+

$project->state->color() === "zinc", + "text-sky-600" => $project->state->color() === "sky", + "text-yellow-600" => $project->state->color() === "yellow", + "text-green-600" => $project->state->color() === "green", + "text-rose-600" => $project->state->color() === "rose", + ])> + {{ $project->state->label() }} +

+
+
$project->state->color() === "zinc", + "bg-sky-200" => $project->state->color() === "sky", + "bg-yellow-200" => $project->state->color() === "yellow", + "bg-green-200" => $project->state->color() === "green", + "bg-rose-200" => $project->state->color() === "rose", + ])> + $project->state->color() === "zinc", + "text-sky-600" => $project->state->color() === "sky", + "text-yellow-600" => $project->state->color() === "yellow", + "text-green-600" => $project->state->color() === "green", + "text-rose-600" => $project->state->color() === "rose", + ])/> +
+
+
+ +
+
+
+

{{ __('project.view.summary_cards.out_total') }}

+

+ {{ $project->posts()->sumMoney('ausgaben') }} +

+
+
+ +
+
+
+ +
+
+
+

{{ __('project.view.summary_cards.out_available') }}

+

$totalRatioAusgaben < 75, + 'text-yellow-600' => 75 <= $totalRatioAusgaben && $totalRatioAusgaben <=100, + 'text-red-600' => $totalRatioAusgaben > 100, + ])> + {{ $totalRemainingAusgaben }} +

+
+
$totalRatioAusgaben < 75, + 'bg-yellow-100' => 75 <= $totalRatioAusgaben && $totalRatioAusgaben <=100, + 'bg-red-100' => $totalRatioAusgaben > 100, + ])> + $totalRatioAusgaben < 75, + 'text-yellow-600' => 75 <= $totalRatioAusgaben && $totalRatioAusgaben <=100, + 'text-red-600' => $totalRatioAusgaben > 100, + ])/> +
+
+
+ +
+
+
+

{{ __('project.view.summary_cards.out_ratio') }}

+

$totalRatioAusgaben < 75, + 'text-green-600' => 75 <= $totalRatioAusgaben && $totalRatioAusgaben <=100, + 'text-red-600' => $totalRatioAusgaben > 100, + ])> + {{ $totalRatioAusgaben }} % +

+
+
$totalRatioAusgaben < 75, + 'bg-green-100' => 75 <= $totalRatioAusgaben && $totalRatioAusgaben <=100, + 'bg-red-100' => $totalRatioAusgaben > 100, + ])> + $totalRatioAusgaben < 75, + 'text-green-600' => 75 <= $totalRatioAusgaben && $totalRatioAusgaben <=100, + 'text-red-600' => $totalRatioAusgaben > 100, + ])/> +
+
+
+ + +
+
+
+

{{ __('project.view.summary_cards.budgetplan') }}

+

+ {{ $project->relatedBudgetPlan()->label() }} +

+
+
+ +
+
+
+ +
+
+
+

{{ __('project.view.summary_cards.in_total') }}

+

+ {{ $totalEinnahmen }} +

+
+
+ +
+
+
+ +
+
+
+

{{ __('project.view.summary_cards.in_available') }}

+

$totalRatioEinnahmen < 75, + 'text-yellow-600' => 75 <= $totalRatioEinnahmen && $totalRatioEinnahmen <=100, + 'text-red-600' => $totalRatioEinnahmen > 100, + ])> + {{ $totalRemainingEinnahmen }} +

+
+
$totalRatioEinnahmen < 75, + 'bg-yellow-100' => 75 <= $totalRatioEinnahmen && $totalRatioEinnahmen <=100, + 'bg-red-100' => $totalRatioEinnahmen > 100, + ])> + $totalRatioEinnahmen < 75, + 'text-yellow-600' => 75 <= $totalRatioEinnahmen && $totalRatioEinnahmen <=100, + 'text-red-600' => $totalRatioEinnahmen > 100, + ])/> +
+
+
+ +
+
+
+

{{ __('project.view.summary_cards.in_ratio') }}

+

$totalRatioEinnahmen < 75, + 'text-green-600' => 75 <= $totalRatioEinnahmen && $totalRatioEinnahmen <=100, + 'text-red-600' => $totalRatioEinnahmen > 100, + ])> + {{ $totalRatioEinnahmen }} % +

+
+
$totalRatioEinnahmen < 75, + 'bg-green-100' => 75 <= $totalRatioEinnahmen && $totalRatioEinnahmen <=100, + 'bg-red-100' => $totalRatioEinnahmen > 100, + ])> + $totalRatioEinnahmen < 75, + 'text-green-600' => 75 <= $totalRatioEinnahmen && $totalRatioEinnahmen <=100, + 'text-red-600' => $totalRatioEinnahmen > 100, + ])/> +
+
+
+ +
+ + @if($showApproval) + +
+

{{ __('project.view.approval.heading') }}

+
+
+ + @empty($project->recht) +

{{ __('project.view.approval.none') }}

+ @else +

{{ $project->getLegal()['label'] }}

+ @endisset +
+
+ @if($project->getLegal()) + + @if($project->recht_additional) +

{{ $project->recht_additional }}

+ @else +

{{ __('project.view.approval.none') }}

+ @endif + @endif +
+ @if(!empty($project->getLegal()['hint-text'])) +
+

{{ $project->getLegal()['hint-text'] ?? '' }}

+
+ @endif +
+
+ @endif + + +
+

{{ __('project.view.details.heading') }}

+ +
+
+ +

+ @empty($project->name) + + @else + {{ $project->name }} + @endisset +

+
+ +
+ + @if(empty($project->responsible)) + + @else + + + {{ $project->responsible }} + + @endisset +
+ +
+ +

+ @empty($project->org) + + @else + {{ $project->org }} + @endisset +

+
+ +
+ + +

+ @empty($project->date_start) + + @else + {{ __('project.view.details.from') }} + {{ $project->date_start?->format('d.m.Y') }} + {{ __('project.view.details.to') }} + {{ $project->date_end?->format('d.m.Y') }} + @endif +

+
+ +
+ +

{{ __('project.view.details.none') }}

+
+
+
+ + +
+
+

{{ __('project.view.budget_table.heading') }}

+

{{ __('project.view.budget_table.subheading') }}

+
+ +
+ + + + + + + + + + + + + + + @foreach($project->posts as $post) + + + + + + + + @php $ratio = $post->expendedRatio() @endphp + + + + @endforeach + + + + + + + + + + +
+ {{ __('project.view.budget_table.nr') }} + + {{ __('project.view.budget_table.group') }} + + {{ __('project.view.budget_table.remark') }} + + {{ __('project.view.budget_table.title') }} + + {{ __('project.view.budget_table.income') }} + + {{ __('project.view.budget_table.expenses') }} + + {{ __('project.view.budget_table.claimed') }} + + {{ __('project.view.budget_table.status') }} +
{{ $loop->iteration }}. + {{ $post->name }}{{ $post->bemerkung }} + @if($post->budgetItem) + {{ $post->budgetItem->titel_name }} ({{ $post->budgetItem->titel_nr }}) + @endif + $post->einnahmen->greaterThan(Money::EUR(0)), + "text-gray-400" => $post->einnahmen->equals(Money::EUR(0)), + ])>{{ $post->einnahmen }} $post->ausgaben->greaterThan(Money::EUR(0)), + "text-gray-400" => $post->ausgaben->equals(Money::EUR(0)), + ])> + {{ $post->ausgaben }} + $ratio === 0, + "text-yellow-600" => 0 < $ratio && $ratio < 75, + "text-green-600" => 75 <= $ratio && $ratio <= 100, + "text-red-600" => $ratio > 100 + ])> + {{ $post->expendedSum() }} + + @if($ratio !== 0) +
+
+
$ratio >= 75 && $ratio <= 100, + "bg-yellow-500"=> $ratio < 75, + "bg-red-500" => $ratio > 100 + ]) style="width: {{ min($ratio,100) }}%">
+
+ $ratio >= 75 && $ratio <= 100, + "text-yellow-600"=> $ratio < 75, + "text-red-600" => $ratio > 100 + ])> + {{ $ratio }}% + +
+ @endif +
Summe + {{ $totalEinnahmen }} + + {{ $totalAusgaben }} +
+
+
+ + +
+

{{ __('project.view.description.heading') }}

+ @empty($project->beschreibung) + + @else +

+ {!! Str::markdown($project->beschreibung) !!} +

+ @endempty +
+ @foreach($project->attachments as $attachment) + + @endforeach +
+
+ + +
+

{{ __('project.view.expenses.heading') }}

+ @if($project->expenses()->count() > 0) + + @else +
+ {{ __('project.view.expenses.none') }} +
+ @endif +
+
+ +
+ +
+ + + +
+

{{ __('project.view.state-modal.heading') }}

+ + @foreach($project->state->transitionableStateInstances() as $state) + +
+ $state->color() === "zinc", + "text-sky-600" => $state->color() === "sky", + "text-yellow-600" => $state->color() === "yellow", + "text-green-600" => $state->color() === "green", + "text-rose-600" => $state->color() === "rose", + ])/>{{ $state->label() }} +
+
+ @endforeach +
+
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ + {{ __('project.view.state-modal.cancel') }} + + + {{ __('project.view.state-modal.save') }} + +
+
+ + + +
+
+
+ + + +
+
+

{{ __('project.view.delete_modal.heading') }}

+
+

{{ __('project.view.delete_modal.intro') }}

+
    +
  • {{ __('project.view.delete_modal.conditions.owner') }}
  • +
  • {{ __('project.view.delete_modal.conditions.no_expenses') }}
  • +
+

{{ __('project.view.delete_modal.warning') }}

+
+
+
+
+ + {{ __('project.view.delete_modal.cancel') }} + + + {{ __('project.view.delete_modal.confirm') }} + +
+
+
+
diff --git a/resources/views/vendor/breadcrumbs/tailwind.blade.php b/resources/views/vendor/breadcrumbs/tailwind.blade.php new file mode 100644 index 00000000..aa6aa45f --- /dev/null +++ b/resources/views/vendor/breadcrumbs/tailwind.blade.php @@ -0,0 +1,16 @@ +{{-- used by the breadcrumbs libary --}} +@unless ($breadcrumbs->isEmpty()) + + @foreach ($breadcrumbs as $breadcrumb) + @if($loop->first) + + {{ $breadcrumb->title }} + + @else + + {{ $breadcrumb->title }} + + @endif + @endforeach + +@endunless diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php new file mode 100644 index 00000000..36e58db8 --- /dev/null +++ b/routes/breadcrumbs.php @@ -0,0 +1,206 @@ +push('Home', route('home')); +}); + +// Home > TODOS +Breadcrumbs::for('legacy.todo.belege', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.todo'), route('legacy.todo.belege')); +}); + +Breadcrumbs::for('legacy.todo.hv', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.todo'), route('legacy.todo.belege')); +}); + +Breadcrumbs::for('legacy.todo.kv', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.todo'), route('legacy.todo.belege')); +}); + +Breadcrumbs::for('legacy.todo.kv.bank', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.todo'), route('legacy.todo.belege')); +}); + +// Home > Booking +Breadcrumbs::for('legacy.booking', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.booking'), route('legacy.booking')); +}); + +Breadcrumbs::for('legacy.booking.instruct', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.booking'), route('legacy.booking')); +}); + +Breadcrumbs::for('legacy.booking.text', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.booking.text'), route('legacy.booking')); +}); + +Breadcrumbs::for('legacy.booking.history', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.booking.history'), route('legacy.booking')); +}); + +// Home > Konto +Breadcrumbs::for('legacy.konto', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.konto'), route('legacy.konto')); +}); + +// Home > Konto > New +Breadcrumbs::for('bank-account.new', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto'); + $trail->push(__('general.breadcrumb.konto.new'), route('bank-account.new')); +}); + +// Home > Konto > Import +Breadcrumbs::for('bank-account.import.csv', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto'); + $trail->push(__('general.breadcrumb.konto.import.csv'), route('bank-account.import.csv')); +}); + +// Home > Konto > Credentials +Breadcrumbs::for('legacy.konto.credentials', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto'); + $trail->push(__('general.breadcrumb.konto.credentials'), route('legacy.konto.credentials')); +}); + +// Home > Konto > Credentials +Breadcrumbs::for('legacy.konto.credentials.new', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto.credentials'); + $trail->push(__('general.breadcrumb.konto.credentials.new'), route('legacy.konto.credentials.new')); +}); + +// Home > Konto > Credentials > Login +Breadcrumbs::for('legacy.konto.credentials.login', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto.credentials'); + $trail->push(__('general.breadcrumb.konto.login')); +}); + +// Home > Konto > Credentials > TAN Mode +Breadcrumbs::for('legacy.konto.credentials.tan-mode', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto.credentials'); + $trail->push(__('general.breadcrumb.konto.tan-mode')); +}); + +// Home > Konto > Credentials > Sepa +Breadcrumbs::for('legacy.konto.credentials.sepa', static function (BreadcrumbTrail $trail, $credential_id): void { + $trail->parent('legacy.konto.credentials'); + $trail->push(__('general.breadcrumb.konto.sepa'), route('legacy.konto.credentials.sepa', $credential_id)); +}); + +// Home > Konto > Credentials > Sepa +Breadcrumbs::for('legacy.konto.credentials.import-konto', static function (BreadcrumbTrail $trail, $credential_id, $shortIban): void { + $trail->parent('legacy.konto.credentials.sepa', $credential_id); + $trail->push(__('general.breadcrumb.konto.import-konto')); +}); + +// Home > Sitzung +Breadcrumbs::for('legacy.sitzung', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.sitzung'), route('legacy.sitzung')); +}); + +// Home > HHP +Breadcrumbs::for('legacy.hhp', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.budget-plan'), route('legacy.hhp')); +}); + +// Home > HHP > Import +Breadcrumbs::for('legacy.hhp.import', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.hhp'); + $trail->push(__('general.breadcrumb.budget-plan-import'), route('legacy.hhp.import')); +}); + +// Home > HHP > $hhp_id +Breadcrumbs::for('legacy.hhp.view', static function (BreadcrumbTrail $trail, $hhp_id): void { + $trail->parent('legacy.hhp'); + $trail->push($hhp_id, route('legacy.hhp.view', $hhp_id)); +}); + +// Home > HHP > $hhp_id > Titel-Details +Breadcrumbs::for('legacy.hhp.titel.view', static function (BreadcrumbTrail $trail, int $hhp_id, int $title_id): void { + $trail->parent('legacy.hhp.view', $hhp_id); + $trail->push(__('general.breadcrumb.hhp-title-details'), route('legacy.hhp.titel.view', [$hhp_id, $title_id])); +}); + +// Home > Projekt > New +Breadcrumbs::for('project.create', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.projekt')); + $trail->push(__('general.breadcrumb.projekt-new'), route('legacy.new-projekt')); +}); + +// Home > Projekt > PID +Breadcrumbs::for('project.show', static function (BreadcrumbTrail $trail, $projekt_id): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.projekt')); + $trail->push($projekt_id, route('project.show', $projekt_id)); +}); + +// Home > Projekt > PID > Edit +Breadcrumbs::for('project.edit', static function (BreadcrumbTrail $trail, $projekt_id): void { + $trail->parent('project.show', $projekt_id); + $trail->push(__('general.breadcrumb.projekt-edit'), route('project.edit', $projekt_id)); +}); + +// Home > Projekt > PID > Abrechnung > AID +Breadcrumbs::for('legacy.expense-long', static function (BreadcrumbTrail $trail, $projekt_id, $auslagen_id): void { + $trail->parent('legacy.projekt', $projekt_id); + $trail->push(__('general.breadcrumb.abrechnung')); + $trail->push($auslagen_id, route('legacy.expense', $auslagen_id)); +}); + +// Home > Projekt > PID > Abrechnung > AID > BelegePDF +Breadcrumbs::for('legacy.belege-pdf', static function (BreadcrumbTrail $trail, $projekt_id, $auslagen_id, $version): void { + $trail->parent('legacy.expense-long', $projekt_id, $auslagen_id); + $trail->push(__('general.breadcrumb.belege-pdf')); +}); + +// Home > Projekt > PID > Abrechnung > AID > Zahlungsanweisung +Breadcrumbs::for('legacy.zahlungsanweisung-pdf', static function (BreadcrumbTrail $trail, $projekt_id, $auslagen_id, $version): void { + $trail->parent('legacy.expense-long', $projekt_id, $auslagen_id); + $trail->push(__('general.breadcrumb.zahlungsanweisung-pdf')); +}); + +/** + * not legacy + */ + +// Home > Budget-Plans +Breadcrumbs::for('budget-plan.index', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.dashboard'); + $trail->push(__('general.breadcrumb.budget-plan'), route('budget-plan.index')); +}); + +// Home > Budget-Plans > ID +Breadcrumbs::for('budget-plan.view', static function (BreadcrumbTrail $trail, $plan_id): void { + $trail->parent('budget-plan.index'); + $trail->push($plan_id, route('budget-plan.view', $plan_id)); +}); + +// Home > Budget-Plans > ID +Breadcrumbs::for('budget-plan.edit', static function (BreadcrumbTrail $trail, $plan_id): void { + $trail->parent('budget-plan.view', $plan_id); + $trail->push(__('general.breadcrumb.budget-plan-edit'), route('budget-plan.edit', $plan_id)); +}); diff --git a/routes/legacy.php b/routes/legacy.php index 20005007..79aaee87 100644 --- a/routes/legacy.php +++ b/routes/legacy.php @@ -4,23 +4,36 @@ use Illuminate\Support\Facades\Route; Route::middleware(['auth'])->name('legacy.')->group(function (): void { + Route::get('menu/hv', [LegacyController::class, 'render'])->name('todo.hv'); Route::get('menu/kv', [LegacyController::class, 'render'])->name('todo.kv'); + Route::get('menu/kv/exportBank', [LegacyController::class, 'render'])->name('todo.kv.bank'); Route::get('menu/belege', [LegacyController::class, 'render'])->name('todo.belege'); Route::get('menu/stura', [LegacyController::class, 'render'])->name('sitzung'); - Route::get('menu/{sub}', [LegacyController::class, 'render'])->name('dashboard'); + Route::get('menu/{hhp_id?}/{sub}', [LegacyController::class, 'render'])->name('dashboard'); // legacy hhp-picker needs that url schema as a easy forward - route names are here not usable :( Route::redirect('konto/{hhp_id}/new', '/bank-account/new'); Route::get('konto/{hhp_id?}/{konto_id?}', [LegacyController::class, 'render'])->name('konto'); Route::get('konto/credentials', [LegacyController::class, 'render'])->name('konto.credentials'); Route::get('konto/credentials/new', [LegacyController::class, 'render'])->name('konto.credentials.new'); + Route::any('konto/credentials/{credential_id}/login', [LegacyController::class, 'render'])->name('konto.credentials.login'); + Route::get('konto/credentials/{credential_id}/tan-mode', [LegacyController::class, 'render'])->name('konto.credentials.tan-mode'); + Route::get('konto/credentials/{credential_id}/sepa', [LegacyController::class, 'render'])->name('konto.credentials.sepa'); + Route::get('konto/credentials/{credential_id}/{short_iban}', [LegacyController::class, 'render'])->name('konto.credentials.import-transactions'); + Route::get('konto/credentials/{credential_id}/{short_iban}/import', [LegacyController::class, 'render'])->name('konto.credentials.import-konto'); Route::get('booking', [LegacyController::class, 'render'])->name('booking'); Route::get('booking/{hhp_id}/instruct', [LegacyController::class, 'render'])->name('booking.instruct'); Route::get('booking/{hhp_id}/text', [LegacyController::class, 'render'])->name('booking.text'); Route::get('booking/{hhp_id}/history', [LegacyController::class, 'render'])->name('booking.history'); - Route::get('hhp/{hhp_id?}', [LegacyController::class, 'render'])->name('hhp'); - Route::get('hhp/{hhp_id}/titel/{titel_id}', [LegacyController::class, 'render']); - Route::get('projekt/create', [LegacyController::class, 'render'])->name('new-project'); + Route::get('hhp', [LegacyController::class, 'render'])->name('hhp'); + Route::get('hhp/import', [LegacyController::class, 'render'])->name('hhp.import'); + Route::get('hhp/{hhp_id}', [LegacyController::class, 'render'])->name('hhp.view'); + Route::get('hhp/{hhp_id}/titel/{titel_id}', [LegacyController::class, 'render'])->name('hhp.titel.view'); + Route::get('projekt/create', [LegacyController::class, 'render'])->name('new-projekt'); + Route::get('projekt/{projekt_id}', [LegacyController::class, 'render'])->name('projekt'); + Route::get('projekt/{projekt_id}/edit', [LegacyController::class, 'render'])->name('projekt.edit'); + Route::get('projekt/{projekt_id}/auslagen/{auslagen_id}', [LegacyController::class, 'render'])->name('expense-long'); + Route::get('projekt/{projekt_id}/auslagen', [LegacyController::class, 'render'])->name('expense.create'); Route::get('files/get/{auslagen_id}/{hash}', [LegacyController::class, 'renderFile'])->name('get-file'); Route::get('auslagen/{auslagen_id}/{fileHash}/{filename}.pdf', [LegacyController::class, 'deliverFile']); @@ -30,11 +43,11 @@ Route::get('projekt/{projekt_id}/auslagen/{auslagen_id}/version/{version}/zahlungsanweisung-pdf/{file_name?}', [LegacyController::class, 'zahlungsanweisungPdf'])->name('zahlungsanweisung-pdf'); - // short link + // short links Route::redirect('p/{projekt_id}', '/projekt/{projekt_id}'); Route::redirect('a/{auslagen_id}', '/auslagen/{auslagen_id}'); Route::get('auslagen/{auslagen_id}', static function ($auslage_id) { - $auslage = \App\Models\Legacy\Expenses::findOrFail($auslage_id); + $auslage = \App\Models\Legacy\Expense::findOrFail($auslage_id); return redirect()->to("projekt/$auslage->projekt_id/auslagen/$auslage->id"); })->name('expense'); @@ -47,9 +60,11 @@ // "new" adapted stuff Route::get('download/hhp/{hhp_id}/{filetype}', [\App\Http\Controllers\Legacy\ExportController::class, 'budgetPlan']); - Route::post('project/{project_id}/delete', \App\Http\Controllers\Legacy\DeleteProject::class)->name('project.delete'); + Route::post('projekt/{projekt_id}/delete', \App\Http\Controllers\Legacy\DeleteProject::class)->name('projekt.delete'); Route::post('expenses/{expenses_id}/delete', \App\Http\Controllers\Legacy\DeleteExpenses::class)->name('expenses.delete'); // catch all - Route::any('{path}', [LegacyController::class, 'render'])->where('path', '.*'); + Route::any('{path}', [LegacyController::class, 'render']) + ->where('path', '.*') + ->name('catch-all'); }); diff --git a/routes/web-dev.php b/routes/web-dev.php index 666d1cb5..950b2927 100644 --- a/routes/web-dev.php +++ b/routes/web-dev.php @@ -10,8 +10,10 @@ // Feature Budget Plans Route::get('plan', [\App\Http\Controllers\BudgetPlanController::class, 'index'])->name('budget-plan.index'); Route::get('plan/create', [\App\Http\Controllers\BudgetPlanController::class, 'create'])->name('budget-plan.create'); - Route::get('plan/{plan_id}', [\App\Http\Controllers\BudgetPlanController::class, 'show'])->name('budget-plan.show'); + Route::get('plan/{plan_id}', [\App\Http\Controllers\BudgetPlanController::class, 'show'])->name('budget-plan.view'); Route::get('plan/{plan_id}/edit', \App\Livewire\BudgetPlan\BudgetPlanEdit::class)->name('budget-plan.edit'); - // Route::get('plan/{plan_id}/edit', \App\Http\Livewire\BudgetPlanLivewire::class)->name('budget-plan.edit'); + + Route::get('year/create', \App\Livewire\FiscalYear\EditFiscalYear::class)->name('fiscal-year.create'); + Route::get('year/{year_id}', \App\Livewire\FiscalYear\EditFiscalYear::class)->name('fiscal-year.edit'); }); diff --git a/routes/web-preview.php b/routes/web-preview.php index 897798fb..58f6565d 100644 --- a/routes/web-preview.php +++ b/routes/web-preview.php @@ -1,3 +1,7 @@ group(function (): void { + // Route::resource('project' , \App\Http\Controllers\ProjectController::class); + Route::get('projects', \App\Livewire\ProjectOverview::class)->name('projects'); +}); diff --git a/routes/web.php b/routes/web.php index 522cfd02..14cb514b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,12 +12,17 @@ */ use App\Http\Controllers\ViewChangelog; +use App\Models\Legacy\LegacyBudgetPlan; use Illuminate\Support\Facades\Route; Route::middleware(['auth'])->group(function (): void { - // legacy is register later, so we cannot route(legacy.dashboard) there - Route::redirect('/', 'menu/mygremium')->name('home'); + Route::get('/', function () { + $sub = Auth::user()->getCommittees()->isEmpty() ? 'allgremium' : 'mygremium'; + $latestPlan = LegacyBudgetPlan::latest(); + + return to_route('legacy.dashboard', ['sub' => $sub, 'hhp_id' => $latestPlan->id]); + })->name('home'); Route::get('bank-account/new', \App\Livewire\NewBankingAccount::class)->name('bank-account.new'); Route::get('bank-account/import/manual', \App\Livewire\TransactionImportWire::class)->name('bank-account.import.csv'); @@ -25,6 +30,12 @@ Route::get('profile', static fn () => redirect(config('stufis.profile_url')))->name('profile'); + Route::get('project/create', \App\Livewire\Project\EditProject::class)->name('project.create'); + Route::get('project/{project_id}', \App\Livewire\Project\ShowProject::class)->name('project.show'); + Route::get('project/{project_id}/history', \App\Livewire\Project\ShowProject::class)->name('project.history'); + Route::get('project/{project_id}/edit', \App\Livewire\Project\EditProject::class)->name('project.edit'); + Route::get('project/attachment/{attachment}/{fileName}', [\App\Http\Controllers\ProjectController::class, 'showAttachment'])->name('project.attachment'); + }); // login routes diff --git a/storage/app/.gitignore b/storage/app/.gitignore index 8f4803c0..fedb287f 100644 --- a/storage/app/.gitignore +++ b/storage/app/.gitignore @@ -1,3 +1,4 @@ * +!private/ !public/ !.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/demo/stufis-demo-data.sql b/storage/demo/stufis-demo-data.sql index f40116af..85bce7e3 100644 --- a/storage/demo/stufis-demo-data.sql +++ b/storage/demo/stufis-demo-data.sql @@ -12,7 +12,7 @@ SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; -INSERT INTO `demo__auslagen` (`id`,`projekt_id`,`name_suffix`,`state`,`ok-belege`,`ok-hv`,`ok-kv`,`payed`,`rejected`,`zahlung-iban`,`zahlung-name`,`zahlung-vwzk`,`address`,`last_change`,`last_change_by`,`etag`,`version`,`created`) VALUES +INSERT INTO `demo__auslagen` (`id`,`projekt_id`,`name_suffix`,`state`,`ok_belege`,`ok_hv`,`ok_kv`,`payed`,`rejected`,`zahlung_iban`,`zahlung_name`,`zahlung_vwzk`,`address`,`last_change`,`last_change_by`,`etag`,`version`,`created`) VALUES (1,1,'April 2023','instructed;2024-10-01 14:06:12;michelle;Michelle Storandt','2024-10-01 14:06:09;michelle;Michelle Storandt','2024-10-01 14:06:03;michelle;Michelle Storandt','2024-10-01 14:06:06;michelle;Michelle Storandt','2024-10-01 14:06:15;michelle;Michelle Storandt','','eyJpdiI6IkdnUUtOMzRxb09TNURyNi9CbWMvbmc9PSIsInZhbHVlIjoiQWF3YmRqQVBPVVNjc0ZmNGpWUmZiM2pRejBYUEp5RGp6WWNoQ2pIMkVLOD0iLCJtYWMiOiJkYzFiY2M5ZTJmMmMyNzRmNGJmZGYyOWQwMDQ0MGY2ZTg3Yzg2MWYwNWE3OWUyZjdkYTM0NzE3YmY5YmRjMmUwIiwidGFnIjoiIn0=','Studierendenrat der fiktiven Uni','','Straße des StuRa 1,98693 fiktive Stadt','2023-08-11 18:10:02','michelle;Michelle Storandt','XMjGWLELiPDcIbMBo0na0ef0wmb4dhg8',14,'2023-05-12 09:55:24;michelle;Michelle Storandt'), (2,2,'Hostsharing März','booked;2024-10-01 14:17:07;michelle;Michelle Storandt','2023-08-11 18:20:51;demo_hv;Service','2023-08-11 18:19:36;demo_hv;Service','2023-08-11 21:43:58;demo_kv;Service','2023-08-11 21:44:29;demo_kv;Service','','eyJpdiI6Ijk1TDNsd1BXNHdTUDYyS2E1Q3lvaGc9PSIsInZhbHVlIjoiUTVuRzFDTmVRNlNkdjFFRzVZdmF0SWovQ2Y2QXlhNGQvSWZvbHhzdzFqQT0iLCJtYWMiOiJmNTY0MDdhMmYzN2IyMWYwMDVmZjcxYmQ2OWY4NDdhNTY2MjI2NjMwZDZkMjA1M2I0NjNlNTFmZTNiMDE3Mjk1IiwidGFnIjoiIn0=','Hostsharing eG','2023-0821-11042','Flughafenstraße 52a,22335 Hamburg','2023-08-11 18:19:17','demo_hv;Service','9p4U5vdOkFN12qbceTdZuj0Cq43xdh80',8,'2023-08-11 18:19:17;demo_hv;Service'), (3,2,'Hostsharing April','booked;2024-10-01 14:17:09;michelle;Michelle Storandt','2023-08-11 18:23:10;demo_hv;Service','2023-08-11 18:23:07;demo_hv;Service','2023-08-11 21:44:00;demo_kv;Service','2023-08-11 21:44:31;demo_kv;Service','','eyJpdiI6Iktva280R2Y5N09palVFTVdhbHprVEE9PSIsInZhbHVlIjoiWXk0RmVFWGZOUkZKbzlXZWlET2FuUUNkS1ljYWNkOThKdnVRekxxTVFuQT0iLCJtYWMiOiJmYmMzZDFiYjViOThjNzc0ODNmMWUzNjQ5MWE2NzEyYjkzNjM3NzIyNTRhMWI3M2Q5MzlkNDc0MWZjYzU2NGI3IiwidGFnIjoiIn0=','Hostsharing eG','2023-1058-11042','Flughafenstraße 52a,22335 Hamburg','2023-08-11 18:22:52','demo_hv;Service','xHr9m7wO3hpxd5hSWkQneNmvvMHmMZVn',8,'2023-08-11 18:22:52;demo_hv;Service'), @@ -86,7 +86,7 @@ INSERT INTO `demo__auslagen` (`id`,`projekt_id`,`name_suffix`,`state`,`ok-belege (71,26,'April','booked;2025-02-04 14:47:58;michelle;Michelle Storandt','2025-02-04 13:23:47;michelle;Michelle Storandt','2025-02-04 13:23:41;michelle;Michelle Storandt','2025-02-04 13:23:44;michelle;Michelle Storandt','2025-02-04 14:43:05;michelle;Michelle Storandt','','eyJpdiI6IlM0dlFJdFdHMFRCRUkzakxnZXBVWlE9PSIsInZhbHVlIjoib0FoeFF3dGF6QktST3RpZi9OWEtnbDEzbHJGVDRpTm5uaVlkc3R6TitsQT0iLCJtYWMiOiI3MDZhMGE5Yzg5NDczOGJiMTNjZWI1Y2QwODkwMGJhZDE0ZTExMmU1ZDUzYjgxZjllYjE1OTE5MTQ0NmJiNzUyIiwidGFnIjoiIn0=','Hostsharing','','Musterstraße 42,12345 Musterstadt','2025-02-04 13:23:22','michelle;Michelle Storandt','QsMAy0yY6puiIsmQjSgbIhpVFluq2N0Z',8,'2025-02-04 13:23:22;michelle;Michelle Storandt'), (72,26,'Mai','booked;2025-02-04 14:47:58;michelle;Michelle Storandt','2025-02-04 13:24:44;michelle;Michelle Storandt','2025-02-04 13:24:38;michelle;Michelle Storandt','2025-02-04 13:24:41;michelle;Michelle Storandt','2025-02-04 14:42:56;michelle;Michelle Storandt','','eyJpdiI6IkMwOEdqTkNqQlhPa2kyMGhCN3R5dGc9PSIsInZhbHVlIjoia0FMSU12cjc1OGxrNlpiKzUxUXRNVVZTTmJkRnB1WWJLZEJNWlBkbXA5az0iLCJtYWMiOiJkNmI0MmM5NDM4NTZiZWJjNmRkODc0YzJiYTAzNGFlMzA5Zjk0YTE3NmM0Zjc0ZTc0OWJiM2U5MWI3NTA0ZjBkIiwidGFnIjoiIn0=','Hostsharing','','Musterstraße 42,12345 Musterstadt','2025-02-04 13:24:29','michelle;Michelle Storandt','0roqdBdg7IJqgvyujNQOgm6VwQgDclkj',8,'2025-02-04 13:24:29;michelle;Michelle Storandt'), (73,26,'Juni','booked;2025-02-04 14:47:58;michelle;Michelle Storandt','2025-02-04 13:26:37;michelle;Michelle Storandt','2025-02-04 13:26:30;michelle;Michelle Storandt','2025-02-04 13:26:34;michelle;Michelle Storandt','2025-02-04 14:44:56;michelle;Michelle Storandt','','eyJpdiI6ImtUbG5SSmRod3hhZEtSRnp2Y0ZZSlE9PSIsInZhbHVlIjoibXM3L2VSWmJWVHl4RDR0Y3JuVGRaZjc2MWtMeHBtR0o5ZjQwUEZ6YWZSQT0iLCJtYWMiOiI3MzI5ZWJlZmVmNWJlYmQ5NTU4YTYzNjYyNzkxNGU2ZDI5ODAxZjNmMjNhMmJhMjIwODcxNTFhNTlkNmRlMDYyIiwidGFnIjoiIn0=','Hostsharing','','Musterstraße 42,12345 Musterstadt','2025-02-04 13:25:59','michelle;Michelle Storandt','ktKRzL8FhP85YrrFS67yLnXYXmUZiqt4',8,'2025-02-04 13:25:59;michelle;Michelle Storandt'); -INSERT INTO `demo__auslagen` (`id`,`projekt_id`,`name_suffix`,`state`,`ok-belege`,`ok-hv`,`ok-kv`,`payed`,`rejected`,`zahlung-iban`,`zahlung-name`,`zahlung-vwzk`,`address`,`last_change`,`last_change_by`,`etag`,`version`,`created`) VALUES +INSERT INTO `demo__auslagen` (`id`,`projekt_id`,`name_suffix`,`state`,`ok_belege`,`ok_hv`,`ok_kv`,`payed`,`rejected`,`zahlung_iban`,`zahlung_name`,`zahlung_vwzk`,`address`,`last_change`,`last_change_by`,`etag`,`version`,`created`) VALUES (74,26,'Juli','booked;2025-02-04 14:54:42;michelle;Michelle Storandt','2025-02-04 13:28:26;michelle;Michelle Storandt','2025-02-04 13:27:51;michelle;Michelle Storandt','2025-02-04 13:28:22;michelle;Michelle Storandt','2025-02-04 14:49:59;michelle;Michelle Storandt','','eyJpdiI6IjBDVDN3YVd1NjVlc0dOWHVIZHllZkE9PSIsInZhbHVlIjoicHEyTXB6ZzV6bjVNOCs4YUI5RHdRbnRrTTZmdjZhWi95b0pKN2tmRUhwST0iLCJtYWMiOiI3YTk2NzM4ZWU1MmUwZGZhODJiM2M3YjMwNjY2ZmRjN2I2ZWVlYjZjNWZjN2EzMzQyM2E3NGEwZTZiZmM1Y2VkIiwidGFnIjoiIn0=','Hostsharing','','Musterstraße 42,12345 Musterstadt','2025-02-04 13:27:42','michelle;Michelle Storandt','fazVOpEWVzBmWNKS5Te2WxxnO5OokyWK',8,'2025-02-04 13:27:42;michelle;Michelle Storandt'), (75,26,'August','booked;2025-02-04 14:54:42;michelle;Michelle Storandt','2025-02-04 13:29:58;michelle;Michelle Storandt','2025-02-04 13:29:52;michelle;Michelle Storandt','2025-02-04 13:29:55;michelle;Michelle Storandt','2025-02-04 14:54:12;michelle;Michelle Storandt','','eyJpdiI6IlZiK2kxd0ZVMVFpZy94YmlBMXpXaVE9PSIsInZhbHVlIjoiRlg3RkFYZ09weDJTWEpuZlBEYVR0cnFDV3cxUHNZeHA2ditTZ0xyZkxvOD0iLCJtYWMiOiIzOWVmZWE1MzE2ZmJiNTNkNzgzNzE2YTExYmE0NTU3NDk1MjRiN2NmZjE2ZDJhM2ExYjBhZjU4MmVmZWNhZDgyIiwidGFnIjoiIn0=','Hostsharing','','Musterstraße 42,12345 Musterstadt','2025-02-04 13:29:41','michelle;Michelle Storandt','GuJU5B0sbuE5zuhRuewdQEKuGukZCbSv',8,'2025-02-04 13:29:41;michelle;Michelle Storandt'), (76,26,'September','booked;2025-02-04 15:39:54;michelle;Michelle Storandt','2025-02-04 13:31:07;michelle;Michelle Storandt','2025-02-04 13:30:59;michelle;Michelle Storandt','2025-02-04 13:31:02;michelle;Michelle Storandt','2025-02-04 15:01:28;michelle;Michelle Storandt','','eyJpdiI6InBZTDJEZlkyc2JMWlRaL21iMTFmTGc9PSIsInZhbHVlIjoiQ1J0L3lVMDg1R0Jxd0huaSs2cFBDYlBYVjZ1cHY4MEF1UlFZQkplUXJ2OD0iLCJtYWMiOiI0YjllZmVhOWViYmJiYmY2MjA0YWUyMjA4NjU0MTFmM2MyOTU1NTA5MThhYWE0OGU2MzcxNTkyMDk0ZjhkMTY0IiwidGFnIjoiIn0=','Hostsharing','','Musterstraße 42,12345 Musterstadt','2025-02-04 13:30:50','michelle;Michelle Storandt','26H2TyveFBtbkL7tFrHj98gxmaX4qv5f',8,'2025-02-04 13:30:50;michelle;Michelle Storandt'), @@ -1067,7 +1067,7 @@ INSERT INTO `demo__konto_type` (`id`,`name`,`short`,`sync_from`,`sync_until`,`ib -- Daten für Tabelle `demo__projekte` -- -INSERT INTO `demo__projekte` (`id`,`creator_id`,`createdat`,`lastupdated`,`version`,`state`,`stateCreator_id`,`name`,`responsible`,`org`,`org-mail`,`protokoll`,`recht`,`recht-additional`,`date-start`,`date-end`,`beschreibung`) VALUES +INSERT INTO `demo__projekte` (`id`,`creator_id`,`createdat`,`lastupdated`,`version`,`state`,`stateCreator_id`,`name`,`responsible`,`org`,`org_mail`,`protokoll`,`recht`,`recht_additional`,`date_start`,`date_end`,`beschreibung`) VALUES (1,1,'2023-05-12 09:32:34','2024-01-11 15:02:27',6,'terminated',1,'Semesterbeiträge','michelle.storandt','Studierendenrat (StuRa)','',NULL,'andere','Haushaltsplan','2023-04-01','2024-03-31',NULL), (2,2,'2023-04-04 00:00:00','2024-01-11 15:02:24',8,'terminated',1,'Hosting',NULL,'Referat IT','',NULL,'andere','Haushaltsplan','2023-04-01','2024-03-31',NULL), (3,3,'2023-07-25 00:00:00','2024-01-11 14:44:09',9,'terminated',1,'Hackathon Dresden','lukas','Fachschaftsrat IA','',NULL,'stura','2023-08-03-F01','2023-08-05','2023-08-07','Für die überregionale Vernetzung unter den IT-lern sowie zum Ausbau der IT-Infrastruktur soll ein Hackathon stattfinden. An diesem nehmen ausschließlich ehrenamtlich Aktive teil. Der Hackathon soll vom 5.8.22 bis 7.8.22 in Dresden stattfinden. Es ist noch unklar,ob die Kosten für die Unterkunft benötigt werden.'), diff --git a/tests/Pest/Legacy/BookingTest.php b/tests/Pest/Legacy/BookingTest.php index 87c2a4b3..e7f42ce0 100644 --- a/tests/Pest/Legacy/BookingTest.php +++ b/tests/Pest/Legacy/BookingTest.php @@ -1,10 +1,10 @@ state('payed') ->make(); // create matching payment diff --git a/tests/Pest/Models/ActorTest.php b/tests/Pest/Models/ActorTest.php index 043ca864..6fba9a44 100644 --- a/tests/Pest/Models/ActorTest.php +++ b/tests/Pest/Models/ActorTest.php @@ -2,9 +2,9 @@ namespace Tests\Feature\Models; -use App\Models\Actor; -use App\Models\ActorMail; -use App\Models\ActorSocial; +use App\Models\PtfProject\Actor; +use App\Models\PtfProject\ActorMail; +use App\Models\PtfProject\ActorSocial; use function Pest\Laravel\assertModelExists; diff --git a/tests/Pest/Models/ApplicationTest.php b/tests/Pest/Models/ApplicationTest.php index 5a156168..f6846b96 100644 --- a/tests/Pest/Models/ApplicationTest.php +++ b/tests/Pest/Models/ApplicationTest.php @@ -1,6 +1,6 @@ forProject()->create(); diff --git a/tests/Pest/Models/FormDefinitionTest.php b/tests/Pest/Models/FormDefinitionTest.php index 1b2dad66..fe9f518d 100644 --- a/tests/Pest/Models/FormDefinitionTest.php +++ b/tests/Pest/Models/FormDefinitionTest.php @@ -1,9 +1,9 @@ has( diff --git a/update-stufis.sh b/update-stufis.sh index b51ca381..d4946c5b 100644 --- a/update-stufis.sh +++ b/update-stufis.sh @@ -11,14 +11,6 @@ cd "$(dirname "$0")" alias php="php8.4" alias composer="php composer.phar" -if [ ! -f "composer.phar" ]; then - php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" - php composer-setup.php - php -r "unlink('composer-setup.php');" -fi - - - # puts StuFis in Maintenance Mode php artisan down --with-secret