diff --git a/REPORTS_BACKEND_REWORK_PLAN.md b/REPORTS_BACKEND_REWORK_PLAN.md new file mode 100644 index 0000000..950c665 --- /dev/null +++ b/REPORTS_BACKEND_REWORK_PLAN.md @@ -0,0 +1,398 @@ +# Reports Backend Rework Plan + +## Overview +Transform the current hardcoded report system into a flexible, database-driven architecture that allows dynamic report configuration without code changes. + +## Current Architecture Analysis + +### Existing Structure +- **Report Classes**: Individual PHP classes (`ActiveContractsReport`, `ActivitiesPerPeriodReport`, etc.) +- **Registry Pattern**: `ReportRegistry` stores report instances in memory +- **Service Provider**: `ReportServiceProvider` registers reports at boot time +- **Base Class**: `BaseEloquentReport` provides common pagination logic +- **Contract Interface**: `Report` interface defines required methods (`slug`, `name`, `description`, `inputs`, `columns`, `query`) +- **Controller**: `ReportController` handles index, show, data, export routes + +### Current Features +1. **Report Definition**: Each report defines: + - Slug (unique identifier) + - Name & Description + - Input parameters (filters) + - Column definitions + - Eloquent query builder +2. **Filter Types**: `date`, `string`, `select:client`, etc. +3. **Export**: PDF and CSV export functionality +4. **Pagination**: Server-side pagination support + +## Proposed New Architecture + +### 1. Database Schema + +#### `reports` Table +```sql +CREATE TABLE reports ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + slug VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + category VARCHAR(100) NULL, -- e.g., 'contracts', 'activities', 'financial' + enabled BOOLEAN DEFAULT TRUE, + order INT DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + INDEX idx_slug (slug), + INDEX idx_enabled_order (enabled, order) +); +``` + +#### `report_entities` Table +Defines which database entities (models) the report queries. + +```sql +CREATE TABLE report_entities ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + model_class VARCHAR(255) NOT NULL, -- e.g., 'App\Models\Contract' + alias VARCHAR(50) NULL, -- table alias for joins + join_type ENUM('base', 'join', 'leftJoin', 'rightJoin') DEFAULT 'base', + join_first VARCHAR(100) NULL, -- first column for join + join_operator VARCHAR(10) NULL, -- =, !=, etc. + join_second VARCHAR(100) NULL, -- second column for join + order INT DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE, + INDEX idx_report_order (report_id, order) +); +``` + +#### `report_columns` Table +Defines selectable columns and their presentation. + +```sql +CREATE TABLE report_columns ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + key VARCHAR(100) NOT NULL, -- column identifier + label VARCHAR(255) NOT NULL, -- display label + type VARCHAR(50) DEFAULT 'string', -- string, number, date, boolean, currency + expression TEXT NOT NULL, -- SQL expression or column path + sortable BOOLEAN DEFAULT TRUE, + visible BOOLEAN DEFAULT TRUE, + order INT DEFAULT 0, + format_options JSON NULL, -- { "decimals": 2, "prefix": "$" } + created_at TIMESTAMP, + updated_at TIMESTAMP, + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE, + INDEX idx_report_order (report_id, order) +); +``` + +#### `report_filters` Table +Defines available filter parameters. + +```sql +CREATE TABLE report_filters ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + key VARCHAR(100) NOT NULL, -- filter identifier + label VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, -- date, string, select, multiselect, number, boolean + nullable BOOLEAN DEFAULT TRUE, + default_value TEXT NULL, + options JSON NULL, -- For select/multiselect: [{"label":"...", "value":"..."}] + data_source VARCHAR(255) NULL, -- e.g., 'clients', 'segments' for dynamic selects + order INT DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE, + INDEX idx_report_order (report_id, order) +); +``` + +#### `report_conditions` Table +Defines WHERE clause conditions (rules) for filtering data. + +```sql +CREATE TABLE report_conditions ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + column VARCHAR(255) NOT NULL, -- e.g., 'contracts.start_date' + operator VARCHAR(50) NOT NULL, -- =, !=, >, <, >=, <=, LIKE, IN, BETWEEN, IS NULL, etc. + value_type VARCHAR(50) NOT NULL, -- static, filter, expression + value TEXT NULL, -- static value or expression + filter_key VARCHAR(100) NULL, -- references report_filters.key + logical_operator ENUM('AND', 'OR') DEFAULT 'AND', + group_id INT NULL, -- for grouping conditions (AND within group, OR between groups) + order INT DEFAULT 0, + enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP, + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE, + INDEX idx_report_group (report_id, group_id, order) +); +``` + +#### `report_orders` Table +Defines default ORDER BY clauses. + +```sql +CREATE TABLE report_orders ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + column VARCHAR(255) NOT NULL, + direction ENUM('ASC', 'DESC') DEFAULT 'ASC', + order INT DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE, + INDEX idx_report_order (report_id, order) +); +``` + +### 2. Model Structure + +#### Report Model +```php +class Report extends Model +{ + protected $fillable = ['slug', 'name', 'description', 'category', 'enabled', 'order']; + protected $casts = ['enabled' => 'boolean']; + + public function entities(): HasMany { return $this->hasMany(ReportEntity::class); } + public function columns(): HasMany { return $this->hasMany(ReportColumn::class); } + public function filters(): HasMany { return $this->hasMany(ReportFilter::class); } + public function conditions(): HasMany { return $this->hasMany(ReportCondition::class); } + public function orders(): HasMany { return $this->hasMany(ReportOrder::class); } +} +``` + +### 3. Query Builder Service + +Create `ReportQueryBuilder` service to dynamically construct queries: + +```php +class ReportQueryBuilder +{ + public function build(Report $report, array $filters = []): Builder + { + // 1. Start with base model query + // 2. Apply joins from report_entities + // 3. Select columns from report_columns + // 4. Apply conditions from report_conditions + // 5. Apply filter values to parameterized conditions + // 6. Apply ORDER BY from report_orders + // 7. Return Builder instance + } +} +``` + +### 4. Backward Compatibility Layer + +Keep existing Report classes but load from database: + +```php +class DatabaseReport extends BaseEloquentReport implements Report +{ + public function __construct(protected Report $dbReport) {} + + public function slug(): string { return $this->dbReport->slug; } + public function name(): string { return $this->dbReport->name; } + public function description(): ?string { return $this->dbReport->description; } + + public function inputs(): array { + return $this->dbReport->filters() + ->orderBy('order') + ->get() + ->map(fn($f) => [ + 'key' => $f->key, + 'type' => $f->type, + 'label' => $f->label, + 'nullable' => $f->nullable, + 'default' => $f->default_value, + 'options' => $f->options, + ]) + ->toArray(); + } + + public function columns(): array { + return $this->dbReport->columns() + ->where('visible', true) + ->orderBy('order') + ->get() + ->map(fn($c) => ['key' => $c->key, 'label' => $c->label]) + ->toArray(); + } + + public function query(array $filters): Builder { + return app(ReportQueryBuilder::class)->build($this->dbReport, $filters); + } +} +``` + +### 5. Migration Strategy + +#### Phase 1: Database Setup +1. Create migrations for all new tables +2. Create models with relationships +3. Create seeders to migrate existing hardcoded reports + +#### Phase 2: Service Layer +1. Build `ReportQueryBuilder` service +2. Build `DatabaseReport` adapter class +3. Update `ReportRegistry` to load from database +4. Create report management CRUD (admin UI) + +#### Phase 3: Testing & Validation +1. Unit tests for query builder +2. Integration tests comparing old vs new results +3. Performance benchmarks +4. Export functionality validation + +#### Phase 4: Migration Seeder +1. Create seeder that converts each hardcoded report into database records +2. Example for `ActiveContractsReport`: + ```php + $report = Report::create([ + 'slug' => 'active-contracts', + 'name' => 'Aktivne pogodbe', + 'description' => 'Pogodbe, ki so aktivne...', + 'enabled' => true, + ]); + + // Add entities (joins) + $report->entities()->create([ + 'model_class' => 'App\Models\Contract', + 'join_type' => 'base', + 'order' => 0, + ]); + + $report->entities()->create([ + 'model_class' => 'App\Models\ClientCase', + 'join_type' => 'join', + 'join_first' => 'contracts.client_case_id', + 'join_operator' => '=', + 'join_second' => 'client_cases.id', + 'order' => 1, + ]); + + // Add columns + $report->columns()->create([ + 'key' => 'contract_reference', + 'label' => 'Pogodba', + 'expression' => 'contracts.reference', + 'order' => 0, + ]); + + // Add filters + $report->filters()->create([ + 'key' => 'client_uuid', + 'label' => 'Stranka', + 'type' => 'select', + 'data_source' => 'clients', + 'nullable' => true, + 'order' => 0, + ]); + + // Add conditions + $report->conditions()->create([ + 'column' => 'contracts.start_date', + 'operator' => '<=', + 'value_type' => 'expression', + 'value' => 'CURRENT_DATE', + 'group_id' => 1, + 'order' => 0, + ]); + ``` + +#### Phase 5: Remove Old Report System +Once the new database-driven system is validated and working: + +1. **Delete Hardcoded Report Classes**: + - Remove `app/Reports/ActiveContractsReport.php` + - Remove `app/Reports/ActivitiesPerPeriodReport.php` + - Remove `app/Reports/ActionsDecisionsCountReport.php` + - Remove `app/Reports/DecisionsCountReport.php` + - Remove `app/Reports/FieldJobsCompletedReport.php` + - Remove `app/Reports/SegmentActivityCountsReport.php` + +2. **Remove Base Classes/Interfaces** (if no longer needed): + - Remove `app/Reports/BaseEloquentReport.php` + - Remove `app/Reports/Contracts/Report.php` interface + +3. **Remove/Update Service Provider**: + - Remove `app/Providers/ReportServiceProvider.php` + - Or update it to only load reports from database + +4. **Update ReportRegistry**: + - Modify to load from database instead of manual registration + - Remove all hardcoded `register()` calls + +5. **Clean Up Config**: + - Remove any report-specific configuration files if they exist + - Update `bootstrap/providers.php` to remove ReportServiceProvider + +6. **Documentation Cleanup**: + - Update any documentation referencing old report classes + - Add migration guide for future report creation + +### 6. Admin UI for Report Management + +Create CRUD interface at `Settings/Reports/*`: +- **Index**: List all reports with enable/disable toggle +- **Create**: Wizard-style form for building new reports +- **Edit**: Visual query builder interface +- **Test**: Preview report results +- **Clone**: Duplicate existing report as starting point + +### 7. Advanced Features (Future) + +1. **Calculated Fields**: Allow expressions like `(column_a + column_b) / 2` +2. **Aggregations**: Support SUM, AVG, COUNT, MIN, MAX +3. **Subqueries**: Define subquery relationships +4. **Report Templates**: Predefined report structures +5. **Scheduled Reports**: Email reports on schedule +6. **Report Sharing**: Share reports with specific users/roles +7. **Version History**: Track report definition changes +8. **Report Permissions**: Control who can view/edit reports + +## Benefits + +1. **No Code Changes**: Add/modify reports through UI +2. **Flexibility**: Non-developers can create reports +3. **Consistency**: All reports follow same structure +4. **Maintainability**: Centralized report logic +5. **Reusability**: Share entities, filters, conditions +6. **Version Control**: Track changes to report definitions +7. **Performance**: Optimize query builder once +8. **Export**: Works with any report automatically + +## Risks & Considerations + +1. **Complexity**: Query builder must handle diverse SQL patterns +2. **Performance**: Dynamic query building overhead +3. **Security**: SQL injection risks with user input +4. **Learning Curve**: Team needs to understand new system +5. **Testing**: Comprehensive test suite required +6. **Migration**: Convert all existing reports correctly +7. **Edge Cases**: Complex queries may be difficult to represent + +## Timeline Estimate + +- **Phase 1 (Database)**: 2-3 days +- **Phase 2 (Services)**: 4-5 days +- **Phase 3 (Testing)**: 2-3 days +- **Phase 4 (Migration)**: 1-2 days +- **Phase 5 (Cleanup)**: 1 day +- **Admin UI**: 3-4 days +- **Total**: 13-18 days + +## Success Criteria + +1. ✅ All existing reports work identically +2. ✅ New reports can be created via UI +3. ✅ Export functionality preserved +4. ✅ Performance within 10% of current +5. ✅ Zero SQL injection vulnerabilities +6. ✅ Comprehensive test coverage (>80%) +7. ✅ Documentation complete diff --git a/REPORTS_FRONTEND_REWORK_PLAN.md b/REPORTS_FRONTEND_REWORK_PLAN.md new file mode 100644 index 0000000..60df866 --- /dev/null +++ b/REPORTS_FRONTEND_REWORK_PLAN.md @@ -0,0 +1,528 @@ +# Reports Frontend Rework Plan + +## Overview +This plan outlines the modernization of Reports frontend pages (`Index.vue` and `Show.vue`) using shadcn-vue components and AppCard containers, following the same patterns established in the Settings pages rework. + +## Current State Analysis + +### Reports/Index.vue (30 lines) +**Current Implementation:** +- Simple grid layout with native divs +- Report cards: `border rounded-lg p-4 bg-white shadow-sm hover:shadow-md` +- Grid: `md:grid-cols-2 lg:grid-cols-3` +- Each card shows: name (h2), description (p), Link to report +- **No shadcn-vue components used** + +**Identified Issues:** +- Native HTML/Tailwind instead of shadcn-vue Card +- Inconsistent with Settings pages styling +- No icons for visual interest +- Basic hover effects only + +### Reports/Show.vue (314 lines) +**Current Implementation:** +- Complex page with filters, export buttons, and data table +- Header section: title, description, export buttons (lines 190-196) + - Buttons: `px-3 py-2 rounded bg-gray-200 hover:bg-gray-300` +- Filter section: grid layout `md:grid-cols-4` (lines 218-270) + - Native inputs: `border rounded px-2 py-1` + - Native selects: `border rounded px-2 py-1` + - DatePicker component (already working) + - Filter buttons: Apply (`bg-indigo-600`) and Reset (`bg-gray-100`) +- Data table: DataTableServer component (lines 285-300) +- Formatting functions: formatNumberEU, formatDateEU, formatDateTimeEU, formatCell + +**Identified Issues:** +- No Card containers for sections +- Native buttons instead of shadcn Button +- Native input/select elements instead of shadcn Input/Select +- No visual separation between sections +- Filter section could be extracted to partial + +## Target Architecture + +### Pattern Reference from Settings Pages + +**Settings/Index.vue Pattern:** +```vue + + +
+ + Title +
+ Description +
+ + + +
+``` + +**Settings/Archive/Index.vue Pattern:** +- Uses AppCard for main container +- Extracted partials: ArchiveRuleCard, CreateRuleForm, EditRuleForm +- Alert components for warnings +- Badge components for status indicators + +## Implementation Plan + +### Phase 1: Reports/Index.vue Rework (Simple) + +**Goal:** Replace native divs with shadcn-vue Card components, add icons + +**Changes:** +1. **Import shadcn-vue components:** + ```js + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card"; + import { Button } from "@/Components/ui/button"; + import { BarChart3, FileText, Activity, Users, TrendingUp, Calendar } from "lucide-vue-next"; + ``` + +2. **Add icon mapping for reports:** + ```js + const reportIcons = { + 'contracts': FileText, + 'field': TrendingUp, + 'activities': Activity, + // fallback icon + default: BarChart3, + }; + + function getReportIcon(category) { + return reportIcons[category] || reportIcons.default; + } + ``` + +3. **Replace report card structure:** + - Remove native `
` + - Use `` + - Structure: + ```vue + + +
+ + {{ report.name }} +
+ {{ report.description }} +
+ + + + + +
+ ``` + +4. **Update page header:** + - Wrap in proper container with consistent spacing + - Match Settings/Index.vue header style + +**Estimated Changes:** +- Lines: 30 → ~65 lines (with imports and icon logic) +- Files modified: 1 (Index.vue) +- Files created: 0 + +**Risk Level:** Low (simple page, straightforward replacement) + +--- + +### Phase 2: Reports/Show.vue Rework - Structure (Medium) + +**Goal:** Add Card containers for sections, replace native buttons + +**Changes:** + +1. **Import shadcn-vue components:** + ```js + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card"; + import { Button } from "@/Components/ui/button"; + import { Input } from "@/Components/ui/input"; + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select"; + import { Label } from "@/Components/ui/label"; + import { Badge } from "@/Components/ui/badge"; + import { Separator } from "@/Components/ui/separator"; + import { Download, Filter, RotateCcw } from "lucide-vue-next"; + ``` + +2. **Wrap header + export buttons in Card:** + ```vue + + +
+
+ {{ name }} + {{ description }} +
+
+ + + +
+
+
+
+ ``` + +3. **Wrap filters in Card:** + ```vue + + +
+ + Filtri +
+
+ + +
+ +
+ +
+ + +
+
+
+ ``` + +4. **Wrap DataTableServer in Card:** + ```vue + + + Rezultati + + Skupaj {{ meta?.total || 0 }} {{ meta?.total === 1 ? 'rezultat' : 'rezultatov' }} + + + + + /> + + + ``` + +5. **Replace all native buttons with shadcn Button:** + - Export buttons: `variant="outline" size="sm"` + - Apply filter button: default variant + - Reset button: `variant="outline"` + +**Estimated Changes:** +- Lines: 314 → ~350 lines (with imports and Card wrappers) +- Files modified: 1 (Show.vue) +- Files created: 0 +- **Keep formatting functions unchanged** (working correctly) + +**Risk Level:** Low-Medium (more complex but no logic changes) + +--- + +### Phase 3: Reports/Show.vue - Replace Native Inputs (Medium) + +**Goal:** Replace native input/select elements with shadcn-vue components + +**Changes:** + +1. **Replace date inputs:** + ```vue + +
+ + +
+ ``` + +2. **Replace text/number inputs:** + ```vue +
+ + +
+ ``` + +3. **Replace select inputs (user/client):** + ```vue +
+ + +
Nalagam…
+
+ ``` + +4. **Update filter grid layout:** + - Change from `md:grid-cols-4` to `md:grid-cols-2 lg:grid-cols-4` + - Use `space-y-2` for label/input spacing + - Consistent gap: `gap-4` + +**Estimated Changes:** +- Lines: ~350 → ~380 lines (shadcn Input/Select have more markup) +- Files modified: 1 (Show.vue) +- Files created: 0 + +**Risk Level:** Medium (v-model binding changes, test thoroughly) + +--- + +### Phase 4: Optional - Extract Filter Section Partial (Optional) + +**Goal:** Reduce Show.vue complexity by extracting filter logic + +**Decision Criteria:** +- If filter section exceeds ~80 lines → extract to partial +- If multiple filter types need separate handling → extract + +**Potential Partial Structure:** +``` +resources/js/Pages/Reports/Partials/ + FilterSection.vue +``` + +**FilterSection.vue:** +- Props: `inputs`, `filters` (reactive object), `userOptions`, `clientOptions`, `loading states` +- Emits: `@apply`, `@reset` +- Contains: entire filter grid + buttons + +**Benefits:** +- Show.vue reduced from ~380 lines to ~300 lines +- Filter logic isolated and reusable +- Easier to maintain filter types + +**Risks:** +- Adds complexity with props/emits +- Might not be worth it if filter logic is simple + +**Recommendation:** Evaluate after Phase 3 completion. If filter section is clean and under 80 lines, skip this phase. + +--- + +## Component Inventory + +### shadcn-vue Components Needed + +**Already Installed (verify):** +- Card, CardHeader, CardTitle, CardDescription, CardContent +- Button +- Input +- Select, SelectTrigger, SelectValue, SelectContent, SelectItem +- Label +- Badge +- Separator + +**Need to Check:** +- lucide-vue-next icons (Download, Filter, RotateCcw, BarChart3, FileText, Activity, TrendingUp, Calendar) + +### Custom Components +- AppCard (if needed for consistency) +- DatePicker (already working, keep as-is) +- DataTableServer (keep as-is) + +--- + +## Testing Checklist + +### Reports/Index.vue Testing: +- [ ] Cards display with correct icons +- [ ] Card hover effects work +- [ ] Links navigate to correct report +- [ ] Grid layout responsive (2 cols MD, 3 cols LG) +- [ ] Icons match report categories + +### Reports/Show.vue Testing: +- [ ] Header Card displays title, description, export buttons +- [ ] Export buttons work (CSV, PDF, Excel) +- [ ] Filter Card displays all filter inputs correctly +- [ ] Date filters use DatePicker component +- [ ] User/Client selects load options async +- [ ] Apply filters button triggers report refresh +- [ ] Reset button clears all filters +- [ ] DataTableServer Card displays results +- [ ] Formatting functions work (dates, numbers, currencies) +- [ ] Pagination works +- [ ] All 6 reports render correctly: + - [ ] active-contracts + - [ ] field-jobs-completed + - [ ] decisions-counts + - [ ] segment-activity-counts + - [ ] actions-decisions-counts + - [ ] activities-per-period + +--- + +## Implementation Order + +### Step 1: Reports/Index.vue (30 min) +1. Import shadcn-vue components + icons +2. Add icon mapping function +3. Replace native divs with Card structure +4. Test navigation and layout +5. Verify responsive grid + +### Step 2: Reports/Show.vue - Structure (45 min) +1. Import shadcn-vue components + icons +2. Wrap header + exports in Card +3. Wrap filters in Card +4. Wrap DataTableServer in Card +5. Replace all native buttons +6. Test all 6 reports + +### Step 3: Reports/Show.vue - Inputs (60 min) +1. Replace text/number inputs with shadcn Input +2. Replace select inputs with shadcn Select +3. Add Label components +4. Test v-model bindings +5. Test async user/client loading +6. Test filter apply/reset +7. Verify all filter types work + +### Step 4: Optional Partial Extraction (30 min, if needed) +1. Create FilterSection.vue partial +2. Move filter logic to partial +3. Set up props/emits +4. Test with all reports + +### Step 5: Final Testing (30 min) +1. Test complete workflow (Index → Show → Filters → Export) +2. Verify all 6 reports +3. Test responsive layouts (mobile, tablet, desktop) +4. Check formatting consistency +5. Verify no regressions + +**Total Estimated Time:** 2.5 - 3.5 hours + +--- + +## Risk Assessment + +### Low Risk: +- Index.vue rework (simple structure, straightforward replacement) +- Adding Card containers to Show.vue +- Replacing native buttons with shadcn Button + +### Medium Risk: +- Replacing native inputs with shadcn Input/Select + - v-model bindings might need adjustments + - Async select loading needs testing + - Number input behavior might differ + +### Mitigation Strategies: +1. Test each phase incrementally +2. Keep formatting functions unchanged (already working) +3. Test v-model bindings immediately after input replacement +4. Verify async loading with console logs +5. Test all 6 reports after each phase +6. Keep git commits small and atomic + +--- + +## Success Criteria + +### Functional Requirements: +✅ All reports navigate from Index page +✅ All filters work correctly (date, text, number, user select, client select) +✅ Apply filters refreshes report data +✅ Reset filters clears all inputs +✅ Export buttons generate CSV/PDF/Excel files +✅ DataTableServer displays results correctly +✅ Pagination works +✅ Formatting functions work (dates, numbers) + +### Visual Requirements: +✅ Consistent Card-based layout +✅ shadcn-vue components throughout +✅ Icons for visual interest +✅ Hover effects on cards +✅ Proper spacing and alignment +✅ Responsive layout (mobile, tablet, desktop) +✅ Matches Settings pages style + +### Code Quality: +✅ No code duplication +✅ Clean component imports +✅ Consistent naming conventions +✅ Proper TypeScript/Vue 3 patterns +✅ Formatting functions unchanged +✅ No regressions in functionality + +--- + +## Notes + +- **DatePicker component:** Already working, imported correctly, no changes needed +- **Formatting functions:** Keep unchanged (formatNumberEU, formatDateEU, formatDateTimeEU, formatCell) +- **DataTableServer:** Keep as-is, already working well +- **Async loading:** User/client select loading works, just needs shadcn Select wrapper +- **Pattern consistency:** Follow Settings/Index.vue and Settings/Archive/Index.vue patterns +- **Icon usage:** Add icons to Index.vue for visual interest, use lucide-vue-next +- **Button variants:** Use `variant="outline"` for secondary actions, default for primary + +--- + +## Post-Implementation + +After completing all phases: + +1. **Documentation:** + - Update this document with actual implementation notes + - Document any deviations from plan + - Note any unexpected issues + +2. **Code Review:** + - Check for consistent component usage + - Verify no native HTML/CSS buttons/inputs remain + - Ensure proper import structure + +3. **User Feedback:** + - Test with actual users + - Gather feedback on UI improvements + - Note any requested adjustments + +4. **Performance:** + - Verify no performance regressions + - Check bundle size impact + - Monitor async loading times + +--- + +## Conclusion + +This plan provides a structured approach to modernizing the Reports frontend pages using shadcn-vue components. The phased approach allows for incremental testing and reduces risk. The estimated total time is 2.5-3.5 hours, with low to medium risk level. + +**Recommendation:** Start with Phase 1 (Index.vue) as a proof of concept, then proceed to Phase 2 and 3 for Show.vue. Evaluate Phase 4 (partial extraction) after Phase 3 completion based on actual complexity. diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 197cd61..6264a67 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -827,6 +827,6 @@ public function destroy(Request $request, Import $import) $import->delete(); - return back()->with(['ok' => true]); + return back()->with('success', 'Import deleted successfully'); } } diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index 7236a18..a4e3f97 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -2,7 +2,8 @@ namespace App\Http\Controllers; -use App\Reports\ReportRegistry; +use App\Models\Report; +use App\Services\ReportQueryBuilder; use Illuminate\Http\Request; use Inertia\Inertia; @@ -10,15 +11,19 @@ class ReportController extends Controller { - public function __construct(protected ReportRegistry $registry) {} + public function __construct(protected ReportQueryBuilder $queryBuilder) {} public function index(Request $request) { - $reports = collect($this->registry->all()) + $reports = Report::where('enabled', true) + ->orderBy('order') + ->orderBy('name') + ->get() ->map(fn ($r) => [ - 'slug' => $r->slug(), - 'name' => $r->name(), - 'description' => $r->description(), + 'slug' => $r->slug, + 'name' => $r->name, + 'description' => $r->description, + 'category' => $r->category, ]) ->values(); @@ -29,26 +34,30 @@ public function index(Request $request) public function show(string $slug, Request $request) { - $report = $this->registry->findBySlug($slug); - abort_if(! $report, 404); - $report->authorize($request); + $report = Report::with(['filters', 'columns']) + ->where('slug', $slug) + ->where('enabled', true) + ->firstOrFail(); // Accept filters & pagination from query and return initial data for server-driven table - $filters = $this->validateFilters($report->inputs(), $request); + $inputs = $this->buildInputsArray($report); + $filters = $this->validateFilters($inputs, $request); \Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]); + $perPage = (int) ($request->integer('per_page') ?: 25); - $paginator = $report->paginate($filters, $perPage); + $query = $this->queryBuilder->build($report, $filters); + $paginator = $query->paginate($perPage); $rows = collect($paginator->items()) ->map(fn ($row) => $this->normalizeRow($row)) ->values(); return Inertia::render('Reports/Show', [ - 'slug' => $report->slug(), - 'name' => $report->name(), - 'description' => $report->description(), - 'inputs' => $report->inputs(), - 'columns' => $report->columns(), + 'slug' => $report->slug, + 'name' => $report->name, + 'description' => $report->description, + 'inputs' => $inputs, + 'columns' => $this->buildColumnsArray($report), 'rows' => $rows, 'meta' => [ 'total' => $paginator->total(), @@ -62,14 +71,17 @@ public function show(string $slug, Request $request) public function data(string $slug, Request $request) { - $report = $this->registry->findBySlug($slug); - abort_if(! $report, 404); - $report->authorize($request); + $report = Report::with(['filters', 'columns']) + ->where('slug', $slug) + ->where('enabled', true) + ->firstOrFail(); - $filters = $this->validateFilters($report->inputs(), $request); + $inputs = $this->buildInputsArray($report); + $filters = $this->validateFilters($inputs, $request); $perPage = (int) ($request->integer('per_page') ?: 25); - $paginator = $report->paginate($filters, $perPage); + $query = $this->queryBuilder->build($report, $filters); + $paginator = $query->paginate($perPage); $rows = collect($paginator->items()) ->map(fn ($row) => $this->normalizeRow($row)) @@ -85,20 +97,23 @@ public function data(string $slug, Request $request) public function export(string $slug, Request $request) { - $report = $this->registry->findBySlug($slug); - abort_if(! $report, 404); - $report->authorize($request); + $report = Report::with(['filters', 'columns']) + ->where('slug', $slug) + ->where('enabled', true) + ->firstOrFail(); - $filters = $this->validateFilters($report->inputs(), $request); + $inputs = $this->buildInputsArray($report); + $filters = $this->validateFilters($inputs, $request); $format = strtolower((string) $request->get('format', 'csv')); - $rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row)); - $columns = $report->columns(); - $filename = $report->slug().'-'.now()->format('Ymd_His'); + $query = $this->queryBuilder->build($report, $filters); + $rows = $query->get()->map(fn ($row) => $this->normalizeRow($row)); + $columns = $this->buildColumnsArray($report); + $filename = $report->slug.'-'.now()->format('Ymd_His'); if ($format === 'pdf') { $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [ - 'name' => $report->name(), + 'name' => $report->name, 'columns' => $columns, 'rows' => $rows, ]); @@ -299,6 +314,35 @@ protected function validateFilters(array $inputs, Request $request): array return $request->validate($rules); } + /** + * Build inputs array from report filters. + */ + protected function buildInputsArray(Report $report): array + { + return $report->filters->map(fn($filter) => [ + 'key' => $filter->key, + 'type' => $filter->type, + 'label' => $filter->label, + 'nullable' => $filter->nullable, + 'default' => $filter->default_value, + 'options' => $filter->options, + ])->toArray(); + } + + /** + * Build columns array from report columns. + */ + protected function buildColumnsArray(Report $report): array + { + return $report->columns + ->where('visible', true) + ->map(fn($col) => [ + 'key' => $col->key, + 'label' => $col->label, + ]) + ->toArray(); + } + /** * Ensure derived export/display fields exist on row objects. */ diff --git a/app/Http/Controllers/Settings/ReportSettingsController.php b/app/Http/Controllers/Settings/ReportSettingsController.php new file mode 100644 index 0000000..6e09a70 --- /dev/null +++ b/app/Http/Controllers/Settings/ReportSettingsController.php @@ -0,0 +1,293 @@ +orderBy('name')->get(); + + return Inertia::render('Settings/Reports/Index', [ + 'reports' => $reports, + ]); + } + + public function edit(Report $report) + { + $report->load(['entities', 'columns', 'filters', 'conditions', 'orders']); + + return Inertia::render('Settings/Reports/Edit', [ + 'report' => $report, + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'slug' => 'required|string|unique:reports,slug|max:255', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'enabled' => 'boolean', + 'order' => 'integer', + ]); + + $report = Report::create($validated); + + return redirect()->route('settings.reports.index') + ->with('success', 'Report created successfully.'); + } + + public function update(Request $request, Report $report) + { + $validated = $request->validate([ + 'slug' => 'required|string|unique:reports,slug,' . $report->id . '|max:255', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'enabled' => 'boolean', + 'order' => 'integer', + ]); + + $report->update($validated); + + return redirect()->route('settings.reports.index') + ->with('success', 'Report updated successfully.'); + } + + public function destroy(Report $report) + { + $report->delete(); + + return redirect()->route('settings.reports.index') + ->with('success', 'Report deleted successfully.'); + } + + public function toggleEnabled(Report $report) + { + $report->update(['enabled' => !$report->enabled]); + + return back()->with('success', 'Report status updated.'); + } + + // Entity CRUD + public function storeEntity(Request $request, Report $report) + { + $validated = $request->validate([ + 'model_class' => 'required|string|max:255', + 'alias' => 'nullable|string|max:50', + 'join_type' => 'required|in:base,join,leftJoin,rightJoin', + 'join_first' => 'nullable|string|max:100', + 'join_operator' => 'nullable|string|max:10', + 'join_second' => 'nullable|string|max:100', + 'order' => 'integer', + ]); + + $report->entities()->create($validated); + + return back()->with('success', 'Entity added successfully.'); + } + + public function updateEntity(Request $request, ReportEntity $entity) + { + $validated = $request->validate([ + 'model_class' => 'required|string|max:255', + 'alias' => 'nullable|string|max:50', + 'join_type' => 'required|in:base,join,leftJoin,rightJoin', + 'join_first' => 'nullable|string|max:100', + 'join_operator' => 'nullable|string|max:10', + 'join_second' => 'nullable|string|max:100', + 'order' => 'integer', + ]); + + $entity->update($validated); + + return back()->with('success', 'Entity updated successfully.'); + } + + public function destroyEntity(ReportEntity $entity) + { + $entity->delete(); + + return back()->with('success', 'Entity deleted successfully.'); + } + + // Column CRUD + public function storeColumn(Request $request, Report $report) + { + $validated = $request->validate([ + 'key' => 'required|string|max:100', + 'label' => 'required|string|max:255', + 'type' => 'required|string|max:50', + 'expression' => 'required|string', + 'sortable' => 'boolean', + 'visible' => 'boolean', + 'order' => 'integer', + 'format_options' => 'nullable|array', + ]); + + $report->columns()->create($validated); + + return back()->with('success', 'Column added successfully.'); + } + + public function updateColumn(Request $request, ReportColumn $column) + { + $validated = $request->validate([ + 'key' => 'required|string|max:100', + 'label' => 'required|string|max:255', + 'type' => 'required|string|max:50', + 'expression' => 'required|string', + 'sortable' => 'boolean', + 'visible' => 'boolean', + 'order' => 'integer', + 'format_options' => 'nullable|array', + ]); + + $column->update($validated); + + return back()->with('success', 'Column updated successfully.'); + } + + public function destroyColumn(ReportColumn $column) + { + $column->delete(); + + return back()->with('success', 'Column deleted successfully.'); + } + + // Filter CRUD + public function storeFilter(Request $request, Report $report) + { + $validated = $request->validate([ + 'key' => 'required|string|max:100', + 'label' => 'required|string|max:255', + 'type' => 'required|string|max:50', + 'nullable' => 'boolean', + 'default_value' => 'nullable|string', + 'options' => 'nullable|array', + 'data_source' => 'nullable|string|max:255', + 'order' => 'integer', + ]); + + $report->filters()->create($validated); + + return back()->with('success', 'Filter added successfully.'); + } + + public function updateFilter(Request $request, ReportFilter $filter) + { + $validated = $request->validate([ + 'key' => 'required|string|max:100', + 'label' => 'required|string|max:255', + 'type' => 'required|string|max:50', + 'nullable' => 'boolean', + 'default_value' => 'nullable|string', + 'options' => 'nullable|array', + 'data_source' => 'nullable|string|max:255', + 'order' => 'integer', + ]); + + $filter->update($validated); + + return back()->with('success', 'Filter updated successfully.'); + } + + public function destroyFilter(ReportFilter $filter) + { + $filter->delete(); + + return back()->with('success', 'Filter deleted successfully.'); + } + + // Condition CRUD + public function storeCondition(Request $request, Report $report) + { + $validated = $request->validate([ + 'column' => 'required|string|max:255', + 'operator' => 'required|string|max:50', + 'value_type' => 'required|in:static,filter,expression', + 'value' => 'nullable|string', + 'filter_key' => 'nullable|string|max:100', + 'logical_operator' => 'required|in:AND,OR', + 'group_id' => 'nullable|integer', + 'order' => 'integer', + 'enabled' => 'boolean', + ]); + + $report->conditions()->create($validated); + + return back()->with('success', 'Condition added successfully.'); + } + + public function updateCondition(Request $request, ReportCondition $condition) + { + $validated = $request->validate([ + 'column' => 'required|string|max:255', + 'operator' => 'required|string|max:50', + 'value_type' => 'required|in:static,filter,expression', + 'value' => 'nullable|string', + 'filter_key' => 'nullable|string|max:100', + 'logical_operator' => 'required|in:AND,OR', + 'group_id' => 'nullable|integer', + 'order' => 'integer', + 'enabled' => 'boolean', + ]); + + $condition->update($validated); + + return back()->with('success', 'Condition updated successfully.'); + } + + public function destroyCondition(ReportCondition $condition) + { + $condition->delete(); + + return back()->with('success', 'Condition deleted successfully.'); + } + + // Order CRUD + public function storeOrder(Request $request, Report $report) + { + $validated = $request->validate([ + 'column' => 'required|string|max:255', + 'direction' => 'required|in:ASC,DESC', + 'order' => 'integer', + ]); + + $report->orders()->create($validated); + + return back()->with('success', 'Order clause added successfully.'); + } + + public function updateOrder(Request $request, ReportOrder $order) + { + $validated = $request->validate([ + 'column' => 'required|string|max:255', + 'direction' => 'required|in:ASC,DESC', + 'order' => 'integer', + ]); + + $order->update($validated); + + return back()->with('success', 'Order clause updated successfully.'); + } + + public function destroyOrder(ReportOrder $order) + { + $order->delete(); + + return back()->with('success', 'Order clause deleted successfully.'); + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 0000000..05feaae --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,48 @@ + 'boolean', + 'order' => 'integer', + ]; + + public function entities(): HasMany + { + return $this->hasMany(ReportEntity::class)->orderBy('order'); + } + + public function columns(): HasMany + { + return $this->hasMany(ReportColumn::class)->orderBy('order'); + } + + public function filters(): HasMany + { + return $this->hasMany(ReportFilter::class)->orderBy('order'); + } + + public function conditions(): HasMany + { + return $this->hasMany(ReportCondition::class)->orderBy('order'); + } + + public function orders(): HasMany + { + return $this->hasMany(ReportOrder::class)->orderBy('order'); + } +} diff --git a/app/Models/ReportColumn.php b/app/Models/ReportColumn.php new file mode 100644 index 0000000..6c63ddd --- /dev/null +++ b/app/Models/ReportColumn.php @@ -0,0 +1,33 @@ + 'boolean', + 'visible' => 'boolean', + 'order' => 'integer', + 'format_options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportCondition.php b/app/Models/ReportCondition.php new file mode 100644 index 0000000..6195ae4 --- /dev/null +++ b/app/Models/ReportCondition.php @@ -0,0 +1,33 @@ + 'boolean', + 'order' => 'integer', + 'group_id' => 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportEntity.php b/app/Models/ReportEntity.php new file mode 100644 index 0000000..149ff58 --- /dev/null +++ b/app/Models/ReportEntity.php @@ -0,0 +1,29 @@ + 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportFilter.php b/app/Models/ReportFilter.php new file mode 100644 index 0000000..a3afd3f --- /dev/null +++ b/app/Models/ReportFilter.php @@ -0,0 +1,32 @@ + 'boolean', + 'order' => 'integer', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Models/ReportOrder.php b/app/Models/ReportOrder.php new file mode 100644 index 0000000..6e57217 --- /dev/null +++ b/app/Models/ReportOrder.php @@ -0,0 +1,25 @@ + 'integer', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } +} diff --git a/app/Providers/ReportServiceProvider.php b/app/Providers/ReportServiceProvider.php deleted file mode 100644 index fbc9d88..0000000 --- a/app/Providers/ReportServiceProvider.php +++ /dev/null @@ -1,36 +0,0 @@ -app->singleton(ReportRegistry::class, function () { - $registry = new ReportRegistry; - // Register built-in reports here - $registry->register(new FieldJobsCompletedReport); - $registry->register(new SegmentActivityCountsReport); - $registry->register(new ActionsDecisionsCountReport); - $registry->register(new ActivitiesPerPeriodReport); - $registry->register(new DecisionsCountReport); - $registry->register(new ActiveContractsReport); - - return $registry; - }); - } - - public function boot(): void - { - // - } -} diff --git a/app/Reports/ActionsDecisionsCountReport.php b/app/Reports/ActionsDecisionsCountReport.php deleted file mode 100644 index 70efda3..0000000 --- a/app/Reports/ActionsDecisionsCountReport.php +++ /dev/null @@ -1,53 +0,0 @@ - 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true], - ['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true], - ]; - } - - public function columns(): array - { - return [ - ['key' => 'action_name', 'label' => 'Dejanje'], - ['key' => 'decision_name', 'label' => 'Odločitev'], - ['key' => 'activities_count', 'label' => 'Št. aktivnosti'], - ]; - } - - public function query(array $filters): Builder - { - return Activity::query() - ->leftJoin('actions', 'activities.action_id', '=', 'actions.id') - ->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id') - ->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from'])) - ->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to'])) - ->groupBy('actions.name', 'decisions.name') - ->selectRaw("COALESCE(actions.name, '—') as action_name, COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count"); - } -} diff --git a/app/Reports/ActiveContractsReport.php b/app/Reports/ActiveContractsReport.php deleted file mode 100644 index 9623dcc..0000000 --- a/app/Reports/ActiveContractsReport.php +++ /dev/null @@ -1,78 +0,0 @@ - 'client_uuid', 'type' => 'select:client', 'label' => 'Stranka', 'nullable' => true], - ]; - } - - public function columns(): array - { - return [ - ['key' => 'contract_reference', 'label' => 'Pogodba'], - ['key' => 'client_name', 'label' => 'Stranka'], - ['key' => 'person_name', 'label' => 'Zadeva (oseba)'], - ['key' => 'start_date', 'label' => 'Začetek'], - ['key' => 'end_date', 'label' => 'Konec'], - ['key' => 'balance_amount', 'label' => 'Saldo'], - ]; - } - - public function query(array $filters): Builder - { - $asOf = now()->toDateString(); - - return Contract::query() - ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') - ->leftJoin('clients', 'client_cases.client_id', '=', 'clients.id') - ->leftJoin('person as client_people', 'clients.person_id', '=', 'client_people.id') - ->leftJoin('person as subject_people', 'client_cases.person_id', '=', 'subject_people.id') - ->leftJoin('accounts', 'contracts.id', '=', 'accounts.contract_id') - ->when(! empty($filters['client_uuid']), fn ($q) => $q->where('clients.uuid', $filters['client_uuid'])) - // Active as of date: start_date <= as_of (or null) AND (end_date is null OR end_date >= as_of) - ->where(function ($q) use ($asOf) { - $q->whereNull('contracts.start_date') - ->orWhereDate('contracts.start_date', '<=', $asOf); - }) - ->where(function ($q) use ($asOf) { - $q->whereNull('contracts.end_date') - ->orWhereDate('contracts.end_date', '>=', $asOf); - }) - ->select([ - 'contracts.id', - 'contracts.start_date', - 'contracts.end_date', - ]) - ->addSelect([ - \DB::raw('contracts.reference as contract_reference'), - \DB::raw('client_people.full_name as client_name'), - \DB::raw('subject_people.full_name as person_name'), - \DB::raw('CAST(accounts.balance_amount AS FLOAT) as balance_amount'), - ]) - ->orderBy('contracts.start_date', 'asc'); - } -} diff --git a/app/Reports/ActivitiesPerPeriodReport.php b/app/Reports/ActivitiesPerPeriodReport.php deleted file mode 100644 index 5d3ec08..0000000 --- a/app/Reports/ActivitiesPerPeriodReport.php +++ /dev/null @@ -1,95 +0,0 @@ - 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true], - ['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true], - ['key' => 'period', 'type' => 'string', 'label' => 'Obdobje (day|week|month)', 'default' => 'day'], - ]; - } - - public function columns(): array - { - return [ - ['key' => 'period', 'label' => 'Obdobje'], - ['key' => 'activities_count', 'label' => 'Št. aktivnosti'], - ]; - } - - public function query(array $filters): Builder - { - $periodRaw = $filters['period'] ?? 'day'; - $period = in_array($periodRaw, ['day', 'week', 'month'], true) ? $periodRaw : 'day'; - $driver = DB::getDriverName(); - - // Build database-compatible period expressions - if ($driver === 'sqlite') { - if ($period === 'day') { - // Use string slice to avoid timezone conversion differences in SQLite - $selectExpr = DB::raw('SUBSTR(activities.created_at, 1, 10) as period'); - $groupExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)'); - $orderExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)'); - } elseif ($period === 'month') { - $selectExpr = DB::raw("strftime('%Y-%m-01', activities.created_at) as period"); - $groupExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)"); - $orderExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)"); - } else { // week - $selectExpr = DB::raw("strftime('%Y-%W', activities.created_at) as period"); - $groupExpr = DB::raw("strftime('%Y-%W', activities.created_at)"); - $orderExpr = DB::raw("strftime('%Y-%W', activities.created_at)"); - } - } elseif ($driver === 'mysql') { - if ($period === 'day') { - $selectExpr = DB::raw('DATE(activities.created_at) as period'); - $groupExpr = DB::raw('DATE(activities.created_at)'); - $orderExpr = DB::raw('DATE(activities.created_at)'); - } elseif ($period === 'month') { - $selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01') as period"); - $groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')"); - $orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')"); - } else { // week - // ISO week-year-week number for grouping; adequate for summary grouping - $selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v') as period"); - $groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')"); - $orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')"); - } - } else { // postgres and others supporting date_trunc - $selectExpr = DB::raw("date_trunc('".$period."', activities.created_at) as period"); - $groupExpr = DB::raw("date_trunc('".$period."', activities.created_at)"); - $orderExpr = DB::raw("date_trunc('".$period."', activities.created_at)"); - } - - return Activity::query() - ->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from'])) - ->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to'])) - ->groupBy($groupExpr) - ->orderBy($orderExpr) - ->select($selectExpr) - ->selectRaw('COUNT(*) as activities_count'); - } -} diff --git a/app/Reports/BaseEloquentReport.php b/app/Reports/BaseEloquentReport.php deleted file mode 100644 index fbe8cee..0000000 --- a/app/Reports/BaseEloquentReport.php +++ /dev/null @@ -1,33 +0,0 @@ - $filters - */ - public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator - { - /** @var EloquentBuilder|QueryBuilder $query */ - $query = $this->query($filters); - - return $query->paginate($perPage); - } -} diff --git a/app/Reports/Contracts/Report.php b/app/Reports/Contracts/Report.php deleted file mode 100644 index 60bf1e3..0000000 --- a/app/Reports/Contracts/Report.php +++ /dev/null @@ -1,54 +0,0 @@ - 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()] - * - * @return array> - */ - public function inputs(): array; - - /** - * Return column definitions for the table and exports. - * Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ] - * - * @return array> - */ - public function columns(): array; - - /** - * Build the data source query for the report based on validated filters. - * Should return an Eloquent or Query builder. - * - * @param array $filters - * @return EloquentBuilder|QueryBuilder - */ - public function query(array $filters); - - /** - * Optional per-report authorization logic. - */ - public function authorize(Request $request): void; - - /** - * Execute the report and return a paginator for UI. - * - * @param array $filters - */ - public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator; -} diff --git a/app/Reports/DecisionsCountReport.php b/app/Reports/DecisionsCountReport.php deleted file mode 100644 index 2fb5dc8..0000000 --- a/app/Reports/DecisionsCountReport.php +++ /dev/null @@ -1,51 +0,0 @@ - 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true], - ['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true], - ]; - } - - public function columns(): array - { - return [ - ['key' => 'decision_name', 'label' => 'Odločitev'], - ['key' => 'activities_count', 'label' => 'Št. aktivnosti'], - ]; - } - - public function query(array $filters): Builder - { - return Activity::query() - ->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id') - ->when(!empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from'])) - ->when(!empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to'])) - ->groupBy('decisions.name') - ->selectRaw("COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count"); - } -} diff --git a/app/Reports/FieldJobsCompletedReport.php b/app/Reports/FieldJobsCompletedReport.php deleted file mode 100644 index 395aa88..0000000 --- a/app/Reports/FieldJobsCompletedReport.php +++ /dev/null @@ -1,60 +0,0 @@ - 'from', 'type' => 'date', 'label' => 'Od', 'default' => now()->startOfMonth()->toDateString()], - ['key' => 'to', 'type' => 'date', 'label' => 'Do', 'default' => now()->toDateString()], - ['key' => 'user_id', 'type' => 'select:user', 'label' => 'Uporabnik', 'default' => null], - ]; - } - - public function columns(): array - { - return [ - ['key' => 'id', 'label' => '#'], - ['key' => 'contract_reference', 'label' => 'Pogodba'], - ['key' => 'assigned_user_name', 'label' => 'Terenski'], - ['key' => 'completed_at', 'label' => 'Zaključeno'], - ['key' => 'notes', 'label' => 'Opombe'], - ]; - } - - /** - * @param array $filters - */ - public function query(array $filters): EloquentBuilder - { - $from = isset($filters['from']) ? now()->parse($filters['from'])->startOfDay() : now()->startOfMonth(); - $to = isset($filters['to']) ? now()->parse($filters['to'])->endOfDay() : now()->endOfDay(); - - return FieldJob::query() - ->whereNull('cancelled_at') - ->whereBetween('completed_at', [$from, $to]) - ->when(! empty($filters['user_id']), fn ($q) => $q->where('assigned_user_id', $filters['user_id'])) - ->with(['assignedUser:id,name', 'contract:id,reference']) - ->select(['id', 'assigned_user_id', 'contract_id', 'completed_at', 'notes']); - } -} diff --git a/app/Reports/ReportRegistry.php b/app/Reports/ReportRegistry.php deleted file mode 100644 index d63ca5f..0000000 --- a/app/Reports/ReportRegistry.php +++ /dev/null @@ -1,29 +0,0 @@ - */ - protected array $reports = []; - - public function register(Report $report): void - { - $this->reports[$report->slug()] = $report; - } - - /** - * @return array - */ - public function all(): array - { - return $this->reports; - } - - public function findBySlug(string $slug): ?Report - { - return $this->reports[$slug] ?? null; - } -} diff --git a/app/Reports/SegmentActivityCountsReport.php b/app/Reports/SegmentActivityCountsReport.php deleted file mode 100644 index f530af1..0000000 --- a/app/Reports/SegmentActivityCountsReport.php +++ /dev/null @@ -1,54 +0,0 @@ - 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true], - ['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true], - ]; - } - - public function columns(): array - { - return [ - ['key' => 'segment_name', 'label' => 'Segment'], - ['key' => 'activities_count', 'label' => 'Št. aktivnosti'], - ]; - } - - public function query(array $filters): Builder - { - $q = Activity::query() - ->join('actions', 'activities.action_id', '=', 'actions.id') - ->leftJoin('segments', 'actions.segment_id', '=', 'segments.id') - ->when(! empty($filters['from']), fn ($qq) => $qq->whereDate('activities.created_at', '>=', $filters['from'])) - ->when(! empty($filters['to']), fn ($qq) => $qq->whereDate('activities.created_at', '<=', $filters['to'])) - ->groupBy('segments.name') - ->selectRaw("COALESCE(segments.name, 'Brez segmenta') as segment_name, COUNT(*) as activities_count"); - - return $q; - } -} diff --git a/app/Services/Import/Handlers/EmailHandler.php b/app/Services/Import/Handlers/EmailHandler.php index 3489acb..4cc4bf5 100644 --- a/app/Services/Import/Handlers/EmailHandler.php +++ b/app/Services/Import/Handlers/EmailHandler.php @@ -73,30 +73,21 @@ public function process(Import $import, array $mapped, array $raw, array $contex ]; } - $existing = $this->resolve($mapped, $context); - - // Check for duplicates if configured - if ($this->getOption('deduplicate', true) && $existing) { - // Update person_id if different - if ($existing->person_id !== $personId) { - $existing->person_id = $personId; - $existing->save(); - - return [ - 'action' => 'updated', - 'entity' => $existing, - 'applied_fields' => ['person_id'], - ]; - } + // Check if this email already exists for THIS person + $existing = Email::where('person_id', $personId) + ->where('value', strtolower(trim($email))) + ->first(); + // If email already exists for this person, skip + if ($existing) { return [ 'action' => 'skipped', 'entity' => $existing, - 'message' => 'Email already exists', + 'message' => 'Email already exists for this person', ]; } - // Create new email + // Create new email for this person $payload = $this->buildPayload($mapped, new Email); $payload['person_id'] = $personId; diff --git a/app/Services/ReportQueryBuilder.php b/app/Services/ReportQueryBuilder.php new file mode 100644 index 0000000..d2f8fe6 --- /dev/null +++ b/app/Services/ReportQueryBuilder.php @@ -0,0 +1,248 @@ +load(['entities', 'columns', 'conditions', 'orders']); + + // 1. Start with base model query + $query = $this->buildBaseQuery($report); + + // 2. Apply joins from report_entities + $this->applyJoins($query, $report); + + // 3. Select columns from report_columns + $this->applySelects($query, $report); + + // 4. Apply GROUP BY if aggregate functions are used + $this->applyGroupBy($query, $report); + + // 5. Apply conditions from report_conditions + $this->applyConditions($query, $report, $filters); + + // 6. Apply ORDER BY from report_orders + $this->applyOrders($query, $report); + + return $query; + } + + /** + * Build the base query from the first entity. + */ + protected function buildBaseQuery(Report $report): Builder + { + $baseEntity = $report->entities->firstWhere('join_type', 'base'); + + if (!$baseEntity) { + throw new \RuntimeException("Report {$report->slug} has no base entity defined."); + } + + $modelClass = $baseEntity->model_class; + + if (!class_exists($modelClass)) { + throw new \RuntimeException("Model class {$modelClass} does not exist."); + } + + return $modelClass::query(); + } + + /** + * Apply joins from report entities. + */ + protected function applyJoins(Builder $query, Report $report): void + { + $entities = $report->entities->where('join_type', '!=', 'base'); + + foreach ($entities as $entity) { + $table = $this->getTableFromModel($entity->model_class); + + // Use alias if provided + if ($entity->alias) { + $table = "{$table} as {$entity->alias}"; + } + + $joinMethod = $entity->join_type; + + $query->{$joinMethod}( + $table, + $entity->join_first, + $entity->join_operator ?? '=', + $entity->join_second + ); + } + } + + /** + * Apply column selections. + */ + protected function applySelects(Builder $query, Report $report): void + { + $columns = $report->columns + ->where('visible', true) + ->map(fn($col) => DB::raw("{$col->expression} as {$col->key}")) + ->toArray(); + + if (!empty($columns)) { + $query->select($columns); + } + } + + /** + * Apply GROUP BY clause if aggregate functions are detected. + */ + protected function applyGroupBy(Builder $query, Report $report): void + { + $visibleColumns = $report->columns->where('visible', true); + + // Check if any column uses aggregate functions + $hasAggregates = $visibleColumns->contains(function ($col) { + return preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression); + }); + + if (!$hasAggregates) { + return; // No aggregates, no grouping needed + } + + // Find non-aggregate columns that need to be in GROUP BY + $groupByColumns = $visibleColumns + ->filter(function ($col) { + // Check if this column does NOT use an aggregate function + return !preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression); + }) + ->map(function ($col) { + // Extract the actual column expression (before any COALESCE, CAST, etc.) + // For COALESCE(segments.name, 'default'), we need segments.name + if (preg_match('/COALESCE\s*\(\s*([^,]+)\s*,/i', $col->expression, $matches)) { + return trim($matches[1]); + } + // For simple columns, use as-is + return $col->expression; + }) + ->filter() // Remove empty values + ->values() + ->toArray(); + + if (!empty($groupByColumns)) { + foreach ($groupByColumns as $column) { + $query->groupBy(DB::raw($column)); + } + } + } + + /** + * Apply conditions (WHERE clauses). + */ + protected function applyConditions(Builder $query, Report $report, array $filters): void + { + $conditions = $report->conditions->where('enabled', true); + + // Group conditions by group_id + $groups = $conditions->groupBy('group_id'); + + foreach ($groups as $groupId => $groupConditions) { + $query->where(function ($subQuery) use ($groupConditions, $filters) { + foreach ($groupConditions as $condition) { + $value = $this->resolveConditionValue($condition, $filters); + + // Skip if filter-based and no value provided + if ($condition->value_type === 'filter' && $value === null) { + continue; + } + + $method = $condition->logical_operator === 'OR' ? 'orWhere' : 'where'; + + $this->applyCondition($subQuery, $condition, $value, $method); + } + }); + } + } + + /** + * Apply a single condition to the query. + */ + protected function applyCondition(Builder $query, $condition, $value, string $method): void + { + $column = $condition->column; + $operator = strtoupper($condition->operator); + + switch ($operator) { + case 'IS NULL': + $query->{$method . 'Null'}($column); + break; + + case 'IS NOT NULL': + $query->{$method . 'NotNull'}($column); + break; + + case 'IN': + $values = is_array($value) ? $value : explode(',', $value); + $query->{$method . 'In'}($column, $values); + break; + + case 'NOT IN': + $values = is_array($value) ? $value : explode(',', $value); + $query->{$method . 'NotIn'}($column, $values); + break; + + case 'BETWEEN': + if (is_array($value) && count($value) === 2) { + $query->{$method . 'Between'}($column, $value); + } + break; + + case 'LIKE': + $query->{$method}($column, 'LIKE', $value); + break; + + default: + $query->{$method}($column, $operator, $value); + break; + } + } + + /** + * Resolve condition value based on value_type. + */ + protected function resolveConditionValue($condition, array $filters) + { + return match ($condition->value_type) { + 'static' => $condition->value, + 'filter' => $filters[$condition->filter_key] ?? null, + 'expression' => DB::raw($condition->value), + default => null, + }; + } + + /** + * Apply ORDER BY clauses. + */ + protected function applyOrders(Builder $query, Report $report): void + { + foreach ($report->orders as $order) { + $query->orderBy($order->column, $order->direction); + } + } + + /** + * Get table name from model class. + */ + protected function getTableFromModel(string $modelClass): string + { + if (!class_exists($modelClass)) { + throw new \RuntimeException("Model class {$modelClass} does not exist."); + } + + return (new $modelClass)->getTable(); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 460af4e..67d2d5f 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -5,5 +5,4 @@ App\Providers\AuthServiceProvider::class, App\Providers\FortifyServiceProvider::class, App\Providers\JetstreamServiceProvider::class, - App\Providers\ReportServiceProvider::class, ]; diff --git a/clean-duplicates.php b/clean-duplicates.php new file mode 100644 index 0000000..8602d0f --- /dev/null +++ b/clean-duplicates.php @@ -0,0 +1,147 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +echo "=== Checking for duplicates ===\n\n"; + +// Check Actions table +echo "ACTIONS TABLE:\n"; +echo "-------------\n"; +$actionDuplicates = DB::table('actions') + ->select('name', DB::raw('COUNT(*) as total_count')) + ->groupBy('name') + ->havingRaw('COUNT(*) > 1') + ->get(); + +if ($actionDuplicates->count() > 0) { + echo "Found duplicate actions:\n"; + foreach ($actionDuplicates as $dup) { + echo " - '{$dup->name}' appears {$dup->total_count} times\n"; + + // Get all IDs for this name + $records = DB::table('actions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id', 'name', 'created_at']); + + echo " IDs: "; + foreach ($records as $record) { + echo $record->id . " "; + } + echo "\n"; + } +} else { + echo "No duplicates found.\n"; +} + +echo "\n"; + +// Check Decisions table +echo "DECISIONS TABLE:\n"; +echo "---------------\n"; +$decisionDuplicates = DB::table('decisions') + ->select('name', DB::raw('COUNT(*) as total_count')) + ->groupBy('name') + ->havingRaw('COUNT(*) > 1') + ->get(); + +if ($decisionDuplicates->count() > 0) { + echo "Found duplicate decisions:\n"; + foreach ($decisionDuplicates as $dup) { + echo " - '{$dup->name}' appears {$dup->total_count} times\n"; + + // Get all IDs for this name + $records = DB::table('decisions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id', 'name', 'created_at']); + + echo " IDs: "; + foreach ($records as $record) { + echo $record->id . " "; + } + echo "\n"; + } +} else { + echo "No duplicates found.\n"; +} + +echo "\n=== Removing duplicates ===\n\n"; + +// Remove duplicate actions (keep the first one) +if ($actionDuplicates->count() > 0) { + foreach ($actionDuplicates as $dup) { + $records = DB::table('actions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id']); + + // Keep the first, delete the rest + $toDelete = $records->skip(1)->pluck('id')->toArray(); + + if (count($toDelete) > 0) { + DB::table('actions')->whereIn('id', $toDelete)->delete(); + echo "Deleted duplicate actions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n"; + } + } +} + +// Remove duplicate decisions (keep the first one) +if ($decisionDuplicates->count() > 0) { + foreach ($decisionDuplicates as $dup) { + $records = DB::table('decisions') + ->where('name', $dup->name) + ->orderBy('id') + ->get(['id']); + + // Keep the first, delete the rest + $toDelete = $records->skip(1)->pluck('id')->toArray(); + + if (count($toDelete) > 0) { + DB::table('decisions')->whereIn('id', $toDelete)->delete(); + echo "Deleted duplicate decisions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n"; + } + } +} + +echo "\n"; + +// Check and clean action_decision pivot table +echo "ACTION_DECISION PIVOT TABLE:\n"; +echo "---------------------------\n"; + +// Find duplicates in pivot table +$pivotDuplicates = DB::table('action_decision') + ->select('action_id', 'decision_id', DB::raw('COUNT(*) as total_count')) + ->groupBy('action_id', 'decision_id') + ->havingRaw('COUNT(*) > 1') + ->get(); + +if ($pivotDuplicates->count() > 0) { + echo "Found duplicate pivot entries:\n"; + foreach ($pivotDuplicates as $dup) { + echo " - action_id: {$dup->action_id}, decision_id: {$dup->decision_id} appears {$dup->total_count} times\n"; + + // Get all IDs for this combination + $records = DB::table('action_decision') + ->where('action_id', $dup->action_id) + ->where('decision_id', $dup->decision_id) + ->orderBy('id') + ->get(['id']); + + // Keep the first, delete the rest + $toDelete = $records->skip(1)->pluck('id')->toArray(); + + if (count($toDelete) > 0) { + DB::table('action_decision')->whereIn('id', $toDelete)->delete(); + echo " Deleted duplicate pivot entries: IDs " . implode(', ', $toDelete) . "\n"; + } + } +} else { + echo "No duplicates found.\n"; +} + +echo "\n=== Cleanup complete ===\n"; diff --git a/database/migrations/2025_12_28_210222_create_reports_table.php b/database/migrations/2025_12_28_210222_create_reports_table.php new file mode 100644 index 0000000..d56e5dc --- /dev/null +++ b/database/migrations/2025_12_28_210222_create_reports_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('slug')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('category', 100)->nullable(); + $table->boolean('enabled')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index('slug'); + $table->index(['enabled', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reports'); + } +}; diff --git a/database/migrations/2025_12_28_210230_create_report_columns_table.php b/database/migrations/2025_12_28_210230_create_report_columns_table.php new file mode 100644 index 0000000..e4f5bbb --- /dev/null +++ b/database/migrations/2025_12_28_210230_create_report_columns_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('key', 100); + $table->string('label'); + $table->string('type', 50)->default('string'); + $table->text('expression'); + $table->boolean('sortable')->default(true); + $table->boolean('visible')->default(true); + $table->integer('order')->default(0); + $table->json('format_options')->nullable(); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_columns'); + } +}; diff --git a/database/migrations/2025_12_28_210230_create_report_entities_table.php b/database/migrations/2025_12_28_210230_create_report_entities_table.php new file mode 100644 index 0000000..51b4c15 --- /dev/null +++ b/database/migrations/2025_12_28_210230_create_report_entities_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('model_class'); + $table->string('alias', 50)->nullable(); + $table->enum('join_type', ['base', 'join', 'leftJoin', 'rightJoin'])->default('base'); + $table->string('join_first', 100)->nullable(); + $table->string('join_operator', 10)->nullable(); + $table->string('join_second', 100)->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_entities'); + } +}; diff --git a/database/migrations/2025_12_28_210230_create_report_filters_table.php b/database/migrations/2025_12_28_210230_create_report_filters_table.php new file mode 100644 index 0000000..c27009a --- /dev/null +++ b/database/migrations/2025_12_28_210230_create_report_filters_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('key', 100); + $table->string('label'); + $table->string('type', 50); + $table->boolean('nullable')->default(true); + $table->text('default_value')->nullable(); + $table->json('options')->nullable(); + $table->string('data_source')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_filters'); + } +}; diff --git a/database/migrations/2025_12_28_210231_create_report_conditions_table.php b/database/migrations/2025_12_28_210231_create_report_conditions_table.php new file mode 100644 index 0000000..8933ec6 --- /dev/null +++ b/database/migrations/2025_12_28_210231_create_report_conditions_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('column'); + $table->string('operator', 50); + $table->string('value_type', 50); + $table->text('value')->nullable(); + $table->string('filter_key', 100)->nullable(); + $table->enum('logical_operator', ['AND', 'OR'])->default('AND'); + $table->integer('group_id')->nullable(); + $table->integer('order')->default(0); + $table->boolean('enabled')->default(true); + $table->timestamps(); + + $table->index(['report_id', 'group_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_conditions'); + } +}; diff --git a/database/migrations/2025_12_28_210231_create_report_orders_table.php b/database/migrations/2025_12_28_210231_create_report_orders_table.php new file mode 100644 index 0000000..bbf8179 --- /dev/null +++ b/database/migrations/2025_12_28_210231_create_report_orders_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('report_id')->constrained()->cascadeOnDelete(); + $table->string('column'); + $table->enum('direction', ['ASC', 'DESC'])->default('ASC'); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index(['report_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_orders'); + } +}; diff --git a/database/seeders/ReportsSeeder.php b/database/seeders/ReportsSeeder.php new file mode 100644 index 0000000..e9c11c3 --- /dev/null +++ b/database/seeders/ReportsSeeder.php @@ -0,0 +1,786 @@ +seedActiveContractsReport(); + $this->seedFieldJobsCompletedReport(); + $this->seedDecisionsCountReport(); + $this->seedSegmentActivityCountsReport(); + $this->seedActionsDecisionsCountReport(); + $this->seedActivitiesPerPeriodReport(); + } + + protected function seedActiveContractsReport(): void + { + $report = Report::create([ + 'slug' => 'active-contracts', + 'name' => 'Aktivne pogodbe', + 'description' => 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.', + 'category' => 'contracts', + 'enabled' => true, + 'order' => 1, + ]); + + // Entities (joins) + $report->entities()->create([ + 'model_class' => 'App\\Models\\Contract', + 'join_type' => 'base', + 'order' => 0, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\ClientCase', + 'join_type' => 'join', + 'join_first' => 'contracts.client_case_id', + 'join_operator' => '=', + 'join_second' => 'client_cases.id', + 'order' => 1, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Client', + 'join_type' => 'leftJoin', + 'join_first' => 'client_cases.client_id', + 'join_operator' => '=', + 'join_second' => 'clients.id', + 'order' => 2, + ]); + + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Person\\Person', + 'alias' => 'client_people', + 'join_type' => 'leftJoin', + 'join_first' => 'clients.person_id', + 'join_operator' => '=', + 'join_second' => 'client_people.id', + 'order' => 3, + ], + [ + 'model_class' => 'App\\Models\\Person\\Person', + 'alias' => 'subject_people', + 'join_type' => 'leftJoin', + 'join_first' => 'client_cases.person_id', + 'join_operator' => '=', + 'join_second' => 'subject_people.id', + 'order' => 4, + ], + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Account', + 'join_type' => 'leftJoin', + 'join_first' => 'contracts.id', + 'join_operator' => '=', + 'join_second' => 'accounts.contract_id', + 'order' => 5, + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'contract_reference', + 'label' => 'Pogodba', + 'type' => 'string', + 'expression' => 'contracts.reference', + 'order' => 0, + ], + [ + 'key' => 'client_name', + 'label' => 'Stranka', + 'type' => 'string', + 'expression' => 'client_people.full_name', + 'order' => 1, + ], + [ + 'key' => 'person_name', + 'label' => 'Zadeva (oseba)', + 'type' => 'string', + 'expression' => 'subject_people.full_name', + 'order' => 2, + ], + [ + 'key' => 'start_date', + 'label' => 'Začetek', + 'type' => 'date', + 'expression' => 'contracts.start_date', + 'order' => 3, + ], + [ + 'key' => 'end_date', + 'label' => 'Konec', + 'type' => 'date', + 'expression' => 'contracts.end_date', + 'order' => 4, + ], + [ + 'key' => 'balance_amount', + 'label' => 'Saldo', + 'type' => 'currency', + 'expression' => 'CAST(accounts.balance_amount AS FLOAT)', + 'order' => 5, + ], + ]); + + // Filters + $report->filters()->create([ + 'key' => 'client_uuid', + 'label' => 'Stranka', + 'type' => 'select:client', + 'nullable' => true, + 'order' => 0, + ]); + + // Conditions - Active as of today + $asOf = 'CURRENT_DATE'; + + // start_date <= as_of (or null) + $report->conditions()->create([ + 'column' => 'contracts.start_date', + 'operator' => '<=', + 'value_type' => 'expression', + 'value' => $asOf, + 'logical_operator' => 'OR', + 'group_id' => 1, + 'order' => 0, + ]); + + $report->conditions()->create([ + 'column' => 'contracts.start_date', + 'operator' => 'IS NULL', + 'value_type' => 'static', + 'logical_operator' => 'OR', + 'group_id' => 1, + 'order' => 1, + ]); + + // end_date >= as_of (or null) + $report->conditions()->create([ + 'column' => 'contracts.end_date', + 'operator' => '>=', + 'value_type' => 'expression', + 'value' => $asOf, + 'logical_operator' => 'OR', + 'group_id' => 2, + 'order' => 0, + ]); + + $report->conditions()->create([ + 'column' => 'contracts.end_date', + 'operator' => 'IS NULL', + 'value_type' => 'static', + 'logical_operator' => 'OR', + 'group_id' => 2, + 'order' => 1, + ]); + + // client_uuid filter condition + $report->conditions()->create([ + 'column' => 'clients.uuid', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'client_uuid', + 'logical_operator' => 'AND', + 'group_id' => 3, + 'order' => 0, + ]); + + // Orders + $report->orders()->create([ + 'column' => 'contracts.start_date', + 'direction' => 'ASC', + 'order' => 0, + ]); + } + + protected function seedFieldJobsCompletedReport(): void + { + $report = Report::create([ + 'slug' => 'field-jobs-completed', + 'name' => 'Zaključeni tereni', + 'description' => 'Pregled zaključenih terenov po datumu in uporabniku.', + 'category' => 'field', + 'enabled' => true, + 'order' => 2, + ]); + + // Base entity + $report->entities()->create([ + 'model_class' => 'App\\Models\\FieldJob', + 'join_type' => 'base', + 'order' => 0, + ]); + + // Join contracts table + $report->entities()->create([ + 'model_class' => 'App\\Models\\Contract', + 'join_type' => 'leftJoin', + 'join_first' => 'field_jobs.contract_id', + 'join_operator' => '=', + 'join_second' => 'contracts.id', + 'order' => 1, + ]); + + // Join users table + $report->entities()->create([ + 'model_class' => 'App\\Models\\User', + 'join_type' => 'leftJoin', + 'join_first' => 'field_jobs.assigned_user_id', + 'join_operator' => '=', + 'join_second' => 'users.id', + 'order' => 2, + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'id', + 'label' => '#', + 'type' => 'number', + 'expression' => 'field_jobs.id', + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'contract_reference', + 'label' => 'Pogodba', + 'type' => 'string', + 'expression' => 'contracts.reference', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + [ + 'key' => 'assigned_user_name', + 'label' => 'Terenski', + 'type' => 'string', + 'expression' => 'users.name', + 'sortable' => true, + 'visible' => true, + 'order' => 2, + ], + [ + 'key' => 'completed_at', + 'label' => 'Zaključeno', + 'type' => 'date', + 'expression' => 'field_jobs.completed_at', + 'sortable' => true, + 'visible' => true, + 'order' => 3, + ], + [ + 'key' => 'notes', + 'label' => 'Opombe', + 'type' => 'string', + 'expression' => 'field_jobs.notes', + 'sortable' => false, + 'visible' => true, + 'order' => 4, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => false, + 'default_value' => now()->startOfMonth()->toDateString(), + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => false, + 'default_value' => now()->toDateString(), + 'order' => 1, + ], + [ + 'key' => 'user_id', + 'label' => 'Uporabnik', + 'type' => 'select:user', + 'nullable' => true, + 'order' => 2, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'field_jobs.cancelled_at', + 'operator' => 'IS NULL', + 'value_type' => 'static', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'field_jobs.completed_at', + 'operator' => 'BETWEEN', + 'value_type' => 'filter', + 'filter_key' => 'from,to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + [ + 'column' => 'field_jobs.assigned_user_id', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'user_id', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 2, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'field_jobs.completed_at', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedDecisionsCountReport(): void + { + $report = Report::create([ + 'slug' => 'decisions-counts', + 'name' => 'Odločitve – štetje', + 'description' => 'Število aktivnosti po odločitvah v izbranem obdobju.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 3, + ]); + + // Entities + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ], + [ + 'model_class' => 'App\\Models\\Decision', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.decision_id', + 'join_operator' => '=', + 'join_second' => 'decisions.id', + 'order' => 1, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'decision_name', + 'label' => 'Odločitev', + 'type' => 'string', + 'expression' => "COALESCE(decisions.name, '—')", + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities_count', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedSegmentActivityCountsReport(): void + { + $report = Report::create([ + 'slug' => 'segment-activity-counts', + 'name' => 'Aktivnosti po segmentih', + 'description' => 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).', + 'category' => 'activities', + 'enabled' => true, + 'order' => 4, + ]); + + // Entities + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ], + [ + 'model_class' => 'App\\Models\\Action', + 'join_type' => 'join', + 'join_first' => 'activities.action_id', + 'join_operator' => '=', + 'join_second' => 'actions.id', + 'order' => 1, + ], + [ + 'model_class' => 'App\\Models\\Segment', + 'join_type' => 'leftJoin', + 'join_first' => 'actions.segment_id', + 'join_operator' => '=', + 'join_second' => 'segments.id', + 'order' => 2, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'segment_name', + 'label' => 'Segment', + 'type' => 'string', + 'expression' => "COALESCE(segments.name, 'Brez segmenta')", + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities_count', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedActionsDecisionsCountReport(): void + { + $report = Report::create([ + 'slug' => 'actions-decisions-counts', + 'name' => 'Dejanja / Odločitve – štetje', + 'description' => 'Število aktivnosti po dejanjih in odločitvah v obdobju.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 5, + ]); + + // Entities + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ], + [ + 'model_class' => 'App\\Models\\Action', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.action_id', + 'join_operator' => '=', + 'join_second' => 'actions.id', + 'order' => 1, + ], + [ + 'model_class' => 'App\\Models\\Decision', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.decision_id', + 'join_operator' => '=', + 'join_second' => 'decisions.id', + 'order' => 2, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'action_name', + 'label' => 'Dejanje', + 'type' => 'string', + 'expression' => "COALESCE(actions.name, '—')", + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'decision_name', + 'label' => 'Odločitev', + 'type' => 'string', + 'expression' => "COALESCE(decisions.name, '—')", + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 2, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities_count', + 'direction' => 'DESC', + 'order' => 0, + ]); + } + + protected function seedActivitiesPerPeriodReport(): void + { + $report = Report::create([ + 'slug' => 'activities-per-period', + 'name' => 'Aktivnosti po obdobjih', + 'description' => 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 6, + ]); + + // Base entity + $report->entities()->create([ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ]); + + // Columns (simplified - period grouping handled in ReportQueryBuilder or controller) + $report->columns()->createMany([ + [ + 'key' => 'period', + 'label' => 'Obdobje', + 'type' => 'string', + 'expression' => 'DATE(activities.created_at)', + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'activities_count', + 'label' => 'Št. aktivnosti', + 'type' => 'number', + 'expression' => 'COUNT(*)', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'from', + 'label' => 'Od', + 'type' => 'date', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'to', + 'label' => 'Do', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + [ + 'key' => 'period', + 'label' => 'Obdobje (day/week/month)', + 'type' => 'string', + 'nullable' => false, + 'default_value' => 'day', + 'order' => 2, + ], + ]); + + // Conditions + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'period', + 'direction' => 'ASC', + 'order' => 0, + ]); + } +} diff --git a/resources/js/Components/DataTable/DataTableClient.vue b/resources/js/Components/DataTable/DataTableClient.vue index 37bdfdd..b7a440d 100644 --- a/resources/js/Components/DataTable/DataTableClient.vue +++ b/resources/js/Components/DataTable/DataTableClient.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from "vue"; import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue"; import EmptyState from "@/Components/EmptyState.vue"; +import DataTablePaginationClient from "@/Components/DataTable/DataTablePaginationClient.vue"; import { Table, TableHeader, @@ -10,6 +11,8 @@ import { TableRow, TableCell, } from "@/Components/ui/table"; +import { Button } from "../ui/button"; +import { ArrowDownNarrowWide, ArrowUpWideNarrowIcon } from "lucide-vue-next"; const props = defineProps({ columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }] @@ -29,6 +32,7 @@ const props = defineProps({ rowKey: { type: [String, Function], default: "id" }, showToolbar: { type: Boolean, default: true }, // Pagination UX options + showPagination: { type: Boolean, default: true }, showPageStats: { type: Boolean, default: true }, showGoto: { type: Boolean, default: true }, maxPageLinks: { type: Number, default: 5 }, // odd number preferred @@ -139,29 +143,6 @@ const pageRows = computed(() => sortedRows.value.slice(startIndex.value, endInde const showingFrom = computed(() => (total.value === 0 ? 0 : startIndex.value + 1)); const showingTo = computed(() => (total.value === 0 ? 0 : endIndex.value)); -const gotoInput = ref(""); -function goToPageInput() { - const raw = String(gotoInput.value || "").trim(); - const n = Number(raw); - if (!Number.isFinite(n)) return; - const target = Math.max(1, Math.min(lastPage.value, Math.floor(n))); - if (target !== currentPage.value) setPage(target); - gotoInput.value = ""; -} - -const visiblePages = computed(() => { - const pages = []; - const count = lastPage.value; - if (count <= 1) return [1]; - const windowSize = Math.max(3, props.maxPageLinks); - const half = Math.floor(windowSize / 2); - let start = Math.max(1, currentPage.value - half); - let end = Math.min(count, start + windowSize - 1); - start = Math.max(1, Math.min(start, end - windowSize + 1)); - for (let p = start; p <= end; p++) pages.push(p); - return pages; -}); - function setPage(p) { emit("update:page", Math.min(Math.max(1, p), lastPage.value)); } @@ -196,28 +177,26 @@ function setPageSize(ps) {
-
- - +
+
+ - + > + {{ col.label }}   @@ -232,11 +211,7 @@ function setPageSize(ps) { @click="$emit('row:click', row)" class="cursor-default hover:bg-gray-50/50" > - + diff --git a/resources/js/Components/DataTable/DataTablePaginationClient.vue b/resources/js/Components/DataTable/DataTablePaginationClient.vue new file mode 100644 index 0000000..1259b30 --- /dev/null +++ b/resources/js/Components/DataTable/DataTablePaginationClient.vue @@ -0,0 +1,205 @@ + + + diff --git a/resources/js/Components/app/ui/AppCheckboxArray.vue b/resources/js/Components/app/ui/AppCheckboxArray.vue new file mode 100644 index 0000000..c822403 --- /dev/null +++ b/resources/js/Components/app/ui/AppCheckboxArray.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/js/Components/app/ui/AppCombobox.vue b/resources/js/Components/app/ui/AppCombobox.vue index 537063f..064335c 100644 --- a/resources/js/Components/app/ui/AppCombobox.vue +++ b/resources/js/Components/app/ui/AppCombobox.vue @@ -55,6 +55,7 @@ const selectedItem = computed(() => function selectItem(selectedValue) { const newValue = selectedValue === props.modelValue ? "" : selectedValue; emit("update:modelValue", newValue); + console.log(selectedValue); open.value = false; } @@ -83,7 +84,11 @@ function selectItem(selectedValue) { v-for="item in items" :key="item.value" :value="item.value" - @select="selectItem" + @select=" + (ev) => { + selectItem(ev.detail.value); + } + " > {{ item.label }} +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; + +const props = defineProps({ + variant: { + type: String, + default: "default", + validator: (value) => ["default", "destructive"].includes(value), + }, + class: { + type: String, + default: "", + }, +}); + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + + + diff --git a/resources/js/Components/ui/alert/AlertDescription.vue b/resources/js/Components/ui/alert/AlertDescription.vue new file mode 100644 index 0000000..2254a20 --- /dev/null +++ b/resources/js/Components/ui/alert/AlertDescription.vue @@ -0,0 +1,16 @@ + + + diff --git a/resources/js/Components/ui/alert/AlertTitle.vue b/resources/js/Components/ui/alert/AlertTitle.vue new file mode 100644 index 0000000..cc6effb --- /dev/null +++ b/resources/js/Components/ui/alert/AlertTitle.vue @@ -0,0 +1,16 @@ + + + diff --git a/resources/js/Components/ui/alert/index.js b/resources/js/Components/ui/alert/index.js new file mode 100644 index 0000000..41ad929 --- /dev/null +++ b/resources/js/Components/ui/alert/index.js @@ -0,0 +1,3 @@ +export { default as Alert } from "./Alert.vue"; +export { default as AlertTitle } from "./AlertTitle.vue"; +export { default as AlertDescription } from "./AlertDescription.vue"; diff --git a/resources/js/Components/ui/checkbox/CheckboxArray.vue b/resources/js/Components/ui/checkbox/CheckboxArray.vue new file mode 100644 index 0000000..c822403 --- /dev/null +++ b/resources/js/Components/ui/checkbox/CheckboxArray.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/js/Pages/Imports/Import.vue b/resources/js/Pages/Imports/Import.vue index 3886eb3..28a46d9 100644 --- a/resources/js/Pages/Imports/Import.vue +++ b/resources/js/Pages/Imports/Import.vue @@ -751,6 +751,14 @@ async function fetchColumns() { async function applyTemplateToImport() { if (!importId.value || !form.value.import_template_id) return; + + // Find the selected template to get its UUID + const template = (props.templates || []).find((t) => t.id === form.value.import_template_id); + if (!template?.uuid) { + console.error('Template UUID not found'); + return; + } + try { if (templateApplied.value) { const ok = window.confirm( @@ -762,7 +770,7 @@ async function applyTemplateToImport() { } await axios.post( route("importTemplates.apply", { - template: form.value.import_template_id, + template: template.uuid, import: importId.value, }), {}, diff --git a/resources/js/Pages/Imports/Index.vue b/resources/js/Pages/Imports/Index.vue index e7ff701..5368a0b 100644 --- a/resources/js/Pages/Imports/Index.vue +++ b/resources/js/Pages/Imports/Index.vue @@ -2,7 +2,16 @@ import AppLayout from "@/Layouts/AppLayout.vue"; import { Link, router } from "@inertiajs/vue3"; import { ref, computed } from "vue"; -import ConfirmationModal from "@/Components/ConfirmationModal.vue"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { @@ -225,41 +234,45 @@ function formatDateTimeNoSeconds(value) { - + + + Potrditev brisanja + + Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz + shrambe, če je še prisotna. + + +

{{ errorMsg }}

+ + - - - -
- - - + Prekliči + + + + + diff --git a/resources/js/Pages/Reports/Index.vue b/resources/js/Pages/Reports/Index.vue index bdef29f..0d7e109 100644 --- a/resources/js/Pages/Reports/Index.vue +++ b/resources/js/Pages/Reports/Index.vue @@ -1,27 +1,105 @@