New report system and views

This commit is contained in:
Simon Pocrnjič 2026-01-02 12:32:20 +01:00
parent 9fc5b54b8a
commit 703b52ff59
67 changed files with 8255 additions and 2794 deletions

View File

@ -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

View File

@ -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
<Card class="hover:shadow-lg transition-shadow">
<CardHeader>
<div class="flex items-center gap-2">
<component :is="icon" class="h-5 w-5 text-muted-foreground" />
<CardTitle>Title</CardTitle>
</div>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
<Button variant="ghost">Action →</Button>
</CardContent>
</Card>
```
**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 `<div class="border rounded-lg p-4 bg-white shadow-sm hover:shadow-md">`
- Use `<Card class="hover:shadow-lg transition-shadow cursor-pointer">`
- Structure:
```vue
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<component :is="getReportIcon(report.category)" class="h-5 w-5 text-muted-foreground" />
<CardTitle>{{ report.name }}</CardTitle>
</div>
<CardDescription>{{ report.description }}</CardDescription>
</CardHeader>
<CardContent>
<Link :href="route('reports.show', report.slug)">
<Button variant="ghost" size="sm" class="w-full justify-start">
Odpri →
</Button>
</Link>
</CardContent>
</Card>
```
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
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>{{ name }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="exportFile('csv')">
<Download class="mr-2 h-4 w-4" />
CSV
</Button>
<Button variant="outline" size="sm" @click="exportFile('pdf')">
<Download class="mr-2 h-4 w-4" />
PDF
</Button>
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
<Download class="mr-2 h-4 w-4" />
Excel
</Button>
</div>
</div>
</CardHeader>
</Card>
```
3. **Wrap filters in Card:**
```vue
<Card class="mb-6">
<CardHeader>
<div class="flex items-center gap-2">
<Filter class="h-5 w-5 text-muted-foreground" />
<CardTitle>Filtri</CardTitle>
</div>
</CardHeader>
<CardContent>
<!-- Filter grid here -->
<div class="grid gap-4 md:grid-cols-4">
<!-- Filter inputs -->
</div>
<Separator class="my-4" />
<div class="flex gap-2">
<Button @click="applyFilters">
<Filter class="mr-2 h-4 w-4" />
Prikaži
</Button>
<Button variant="outline" @click="resetFilters">
<RotateCcw class="mr-2 h-4 w-4" />
Ponastavi
</Button>
</div>
</CardContent>
</Card>
```
4. **Wrap DataTableServer in Card:**
```vue
<Card>
<CardHeader>
<CardTitle>Rezultati</CardTitle>
<CardDescription>
Skupaj {{ meta?.total || 0 }} {{ meta?.total === 1 ? 'rezultat' : 'rezultatov' }}
</CardDescription>
</CardHeader>
<CardContent>
<DataTableServer
<!-- props -->
/>
</CardContent>
</Card>
```
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
<!-- Keep DatePicker as-is (already working) -->
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<DatePicker
v-model="filters[inp.key]"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</div>
```
2. **Replace text/number inputs:**
```vue
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<Input
v-model="filters[inp.key]"
:type="inp.type === 'integer' ? 'number' : 'text'"
placeholder="Vnesi vrednost"
/>
</div>
```
3. **Replace select inputs (user/client):**
```vue
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<Select v-model="filters[inp.key]">
<SelectTrigger>
<SelectValue placeholder="— brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">— brez —</SelectItem>
<SelectItem v-for="u in userOptions" :key="u.id" :value="u.id">
{{ u.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="userLoading" class="text-xs text-muted-foreground">Nalagam…</div>
</div>
```
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.

View File

@ -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');
}
}

View File

@ -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.
*/

View File

@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Report;
use App\Models\ReportEntity;
use App\Models\ReportColumn;
use App\Models\ReportFilter;
use App\Models\ReportCondition;
use App\Models\ReportOrder;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ReportSettingsController extends Controller
{
public function index()
{
$reports = Report::orderBy('order')->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.');
}
}

48
app/Models/Report.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Report extends Model
{
protected $fillable = [
'slug',
'name',
'description',
'category',
'enabled',
'order',
];
protected $casts = [
'enabled' => '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');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportColumn extends Model
{
protected $fillable = [
'report_id',
'key',
'label',
'type',
'expression',
'sortable',
'visible',
'order',
'format_options',
];
protected $casts = [
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportCondition extends Model
{
protected $fillable = [
'report_id',
'column',
'operator',
'value_type',
'value',
'filter_key',
'logical_operator',
'group_id',
'order',
'enabled',
];
protected $casts = [
'enabled' => 'boolean',
'order' => 'integer',
'group_id' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportEntity extends Model
{
protected $fillable = [
'report_id',
'model_class',
'alias',
'join_type',
'join_first',
'join_operator',
'join_second',
'order',
];
protected $casts = [
'order' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportFilter extends Model
{
protected $fillable = [
'report_id',
'key',
'label',
'type',
'nullable',
'default_value',
'options',
'data_source',
'order',
];
protected $casts = [
'nullable' => 'boolean',
'order' => 'integer',
'options' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportOrder extends Model
{
protected $fillable = [
'report_id',
'column',
'direction',
'order',
];
protected $casts = [
'order' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Providers;
use App\Reports\ActionsDecisionsCountReport;
use App\Reports\ActivitiesPerPeriodReport;
use App\Reports\ActiveContractsReport;
use App\Reports\FieldJobsCompletedReport;
use App\Reports\DecisionsCountReport;
use App\Reports\ReportRegistry;
use App\Reports\SegmentActivityCountsReport;
use Illuminate\Support\ServiceProvider;
class ReportServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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
{
//
}
}

View File

@ -1,53 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class ActionsDecisionsCountReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'actions-decisions-counts';
}
public function name(): string
{
return 'Dejanja / Odločitve štetje';
}
public function description(): ?string
{
return 'Število aktivnosti po dejanjih in odločitvah v obdobju.';
}
public function inputs(): array
{
return [
['key' => '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");
}
}

View File

@ -1,78 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Contract;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class ActiveContractsReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'active-contracts';
}
public function name(): string
{
return 'Aktivne pogodbe';
}
public function description(): ?string
{
return 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.';
}
public function inputs(): array
{
return [
['key' => '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');
}
}

View File

@ -1,95 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ActivitiesPerPeriodReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'activities-per-period';
}
public function name(): string
{
return 'Aktivnosti po obdobjih';
}
public function description(): ?string
{
return 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.';
}
public function inputs(): array
{
return [
['key' => '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');
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace App\Reports;
use App\Reports\Contracts\Report;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
abstract class BaseEloquentReport implements Report
{
public function description(): ?string
{
return null;
}
public function authorize(Request $request): void
{
// Default: no extra checks. Controllers can gate via middleware.
}
/**
* @param array<string, mixed> $filters
*/
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator
{
/** @var EloquentBuilder|QueryBuilder $query */
$query = $this->query($filters);
return $query->paginate($perPage);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Reports\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
interface Report
{
public function slug(): string;
public function name(): string;
public function description(): ?string;
/**
* Return an array describing input filters (type, label, default, options) for UI.
* Example item: ['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()]
*
* @return array<int, array<string, mixed>>
*/
public function inputs(): array;
/**
* Return column definitions for the table and exports.
* Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ]
*
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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<string, mixed> $filters
*/
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator;
}

View File

@ -1,51 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class DecisionsCountReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'decisions-counts';
}
public function name(): string
{
return 'Odločitve štetje';
}
public function description(): ?string
{
return 'Število aktivnosti po odločitvah v izbranem obdobju.';
}
public function inputs(): array
{
return [
['key' => '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");
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Reports;
use App\Models\FieldJob;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
class FieldJobsCompletedReport extends BaseEloquentReport
{
public function slug(): string
{
return 'field-jobs-completed';
}
public function name(): string
{
return 'Zaključeni tereni';
}
public function description(): ?string
{
return 'Pregled zaključenih terenov po datumu in uporabniku.';
}
public function inputs(): array
{
return [
['key' => '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<string, mixed> $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']);
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Reports;
use App\Reports\Contracts\Report;
class ReportRegistry
{
/** @var array<string, Report> */
protected array $reports = [];
public function register(Report $report): void
{
$this->reports[$report->slug()] = $report;
}
/**
* @return array<string, Report>
*/
public function all(): array
{
return $this->reports;
}
public function findBySlug(string $slug): ?Report
{
return $this->reports[$slug] ?? null;
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class SegmentActivityCountsReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'segment-activity-counts';
}
public function name(): string
{
return 'Aktivnosti po segmentih';
}
public function description(): ?string
{
return 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).';
}
public function inputs(): array
{
return [
['key' => '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;
}
}

View File

@ -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;

View File

@ -0,0 +1,248 @@
<?php
namespace App\Services;
use App\Models\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ReportQueryBuilder
{
/**
* Build a query from a database-driven report configuration.
*/
public function build(Report $report, array $filters = []): Builder
{
// Load all required relationships
$report->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();
}
}

View File

@ -5,5 +5,4 @@
App\Providers\AuthServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\ReportServiceProvider::class,
];

147
clean-duplicates.php Normal file
View File

@ -0,0 +1,147 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->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";

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reports', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_columns', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_entities', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_filters', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_conditions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_orders', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,786 @@
<?php
namespace Database\Seeders;
use App\Models\Report;
use Illuminate\Database\Seeder;
class ReportsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Clear existing reports (cascade will delete all related records)
Report::truncate();
$this->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,
]);
}
}

View File

@ -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) {
</div>
</div>
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<Table class="text-sm">
<TableHeader
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<div>
<Table class="border-t">
<TableHeader>
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<button
<Button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
variant="ghost"
class="text-left gap-1 p-1"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"
><ArrowDownNarrowWide
/></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
>
</button>
><ArrowUpWideNarrowIcon
/></span>
</Button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
@ -232,11 +211,7 @@ function setPageSize(ps) {
@click="$emit('row:click', row)"
class="cursor-default hover:bg-gray-50/50"
>
<TableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
>
<TableCell v-for="col in columns" :key="col.key" :class="col.class">
<template v-if="$slots['cell-' + col.key]">
<slot
:name="'cell-' + col.key"
@ -275,10 +250,7 @@ function setPageSize(ps) {
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<EmptyState
:title="emptyText"
size="sm"
/>
<EmptyState :title="emptyText" size="sm" />
</slot>
</TableCell>
</TableRow>
@ -286,112 +258,19 @@ function setPageSize(ps) {
</TableBody>
</Table>
</div>
<nav
v-if="showPagination"
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
aria-label="Pagination"
>
<div v-if="showPageStats">
<span v-if="total > 0"
>Prikazano: {{ showingFrom }}{{ showingTo }} od {{ total }}</span
>
<span v-else>Ni zadetkov</span>
</div>
<div class="flex items-center gap-1">
<!-- First -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage <= 1"
@click="setPage(1)"
aria-label="Prva stran"
>
««
</button>
<!-- Prev -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage <= 1"
@click="setPage(currentPage - 1)"
aria-label="Prejšnja stran"
>
«
</button>
<!-- Leading ellipsis / first page when window doesn't include 1 -->
<button
v-if="visiblePages[0] > 1"
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50"
@click="setPage(1)"
>
1
</button>
<span v-if="visiblePages[0] > 2" class="px-1"></span>
<!-- Page numbers -->
<button
v-for="p in visiblePages"
:key="p"
class="px-3 py-1 rounded border transition-colors"
:class="
p === currentPage
? 'border-indigo-600 bg-indigo-600 text-white'
: 'border-gray-300 hover:bg-gray-50'
"
:aria-current="p === currentPage ? 'page' : undefined"
@click="setPage(p)"
>
{{ p }}
</button>
<!-- Trailing ellipsis / last page when window doesn't include last -->
<span v-if="visiblePages[visiblePages.length - 1] < lastPage - 1" class="px-1"
></span
>
<button
v-if="visiblePages[visiblePages.length - 1] < lastPage"
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50"
@click="setPage(lastPage)"
>
{{ lastPage }}
</button>
<!-- Next -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage >= lastPage"
@click="setPage(currentPage + 1)"
aria-label="Naslednja stran"
>
»
</button>
<!-- Last -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage >= lastPage"
@click="setPage(lastPage)"
aria-label="Zadnja stran"
>
»»
</button>
<!-- Goto page -->
<div v-if="showGoto" class="ms-2 flex items-center gap-1">
<input
v-model="gotoInput"
type="number"
min="1"
:max="lastPage"
inputmode="numeric"
class="w-16 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 text-sm"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPageInput"
@blur="goToPageInput"
/>
<span class="text-gray-500">/ {{ lastPage }}</span>
</div>
</div>
</nav>
<div class="px-2 py-4 border-t">
<DataTablePaginationClient
v-if="showPagination"
:current-page="currentPage"
:last-page="lastPage"
:total="total"
:showing-from="showingFrom"
:showing-to="showingTo"
:show-page-stats="showPageStats"
:show-goto="showGoto"
:max-page-links="maxPageLinks"
@update:page="setPage"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,205 @@
<script setup>
import { ref, computed } from "vue";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious,
} from "@/Components/ui/pagination";
import { Separator } from "@/Components/ui/separator";
const props = defineProps({
currentPage: { type: Number, required: true },
lastPage: { type: Number, required: true },
total: { type: Number, required: true },
showingFrom: { type: Number, required: true },
showingTo: { type: Number, required: true },
showPageStats: { type: Boolean, default: true },
showGoto: { type: Boolean, default: true },
maxPageLinks: { type: Number, default: 5 },
perPage: { type: Number, default: 10 },
});
const emit = defineEmits(["update:page"]);
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(props.lastPage, Math.floor(n)));
if (target !== props.currentPage) setPage(target);
gotoInput.value = "";
}
const visiblePages = computed(() => {
const pages = [];
const count = props.lastPage;
if (count <= 1) return [1];
const windowSize = Math.max(3, props.maxPageLinks);
const half = Math.floor(windowSize / 2);
let start = Math.max(1, props.currentPage - half);
let end = Math.min(count, start + windowSize - 1);
start = Math.max(1, Math.min(start, end - windowSize + 1));
// Handle first page
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("...");
}
// Add pages in window
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Handle last page
if (end < count) {
if (end < count - 1) pages.push("...");
pages.push(count);
}
return pages;
});
function setPage(p) {
emit("update:page", Math.min(Math.max(1, p), props.lastPage));
}
</script>
<template>
<nav
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-1"
aria-label="Pagination"
>
<!-- Mobile: Simple prev/next -->
<div class="flex flex-1 justify-between sm:hidden">
<button
@click="setPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Prejšnja
</button>
<button
@click="setPage(currentPage + 1)"
:disabled="currentPage >= lastPage"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Naslednja
</button>
</div>
<!-- Desktop: Full pagination -->
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<!-- Page stats with modern badge style -->
<div v-if="showPageStats">
<div v-if="total > 0" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Prikazano</span>
<div
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm font-medium"
>
<span class="text-foreground">{{ showingFrom }}</span>
<span class="text-muted-foreground">-</span>
<span class="text-foreground">{{ showingTo }}</span>
</div>
<span class="text-sm text-muted-foreground">od</span>
<div
class="inline-flex items-center rounded-md bg-primary/10 px-2.5 py-1 text-sm font-semibold text-primary"
>
{{ total }}
</div>
</div>
<div
v-else
class="inline-flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5"
>
<span class="text-sm font-medium text-muted-foreground">Ni zadetkov</span>
</div>
</div>
<!-- Pagination controls -->
<Pagination
v-slot="{ page }"
:total="total"
:items-per-page="perPage"
:sibling-count="1"
show-edges
:default-page="currentPage"
:page="currentPage"
>
<PaginationContent>
<!-- First -->
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
<ChevronsLeft />
</PaginationFirst>
<!-- Previous -->
<PaginationPrevious
:disabled="currentPage <= 1"
@click="setPage(currentPage - 1)"
>
<ChevronLeft />
</PaginationPrevious>
<!-- Page numbers -->
<template v-for="(item, index) in visiblePages" :key="index">
<PaginationEllipsis v-if="item === '...'" />
<PaginationItem
v-else
:value="item"
:is-active="currentPage === item"
@click="setPage(item)"
>
{{ item }}
</PaginationItem>
</template>
<!-- Next -->
<PaginationNext
:disabled="currentPage >= lastPage"
@click="setPage(currentPage + 1)"
>
<ChevronRight />
</PaginationNext>
<!-- Last -->
<PaginationLast
:disabled="currentPage >= lastPage"
@click="setPage(lastPage)"
>
<ChevronsRight />
</PaginationLast>
</PaginationContent>
</Pagination>
<!-- Goto page input -->
<div v-if="showGoto" class="flex items-center gap-3">
<div
class="inline-flex items-center gap-2 rounded-md border border-input bg-background px-2 h-8"
>
<input
v-model="gotoInput"
type="number"
min="1"
:max="lastPage"
inputmode="numeric"
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPageInput"
@blur="goToPageInput"
/>
<Separator orientation="vertical" class="h-full" />
<span class="text-sm text-muted-foreground">{{ lastPage }}</span>
</div>
</div>
</div>
</nav>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
import { computed } from "vue";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
modelValue: { type: [Boolean, Array], required: true },
value: { type: [String, Number], required: false },
disabled: { type: Boolean, default: false },
id: { type: String, required: false },
class: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue"]);
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value);
}
return props.modelValue;
});
function handleChange(checked) {
if (Array.isArray(props.modelValue)) {
const newValue = [...props.modelValue];
if (checked) {
if (!newValue.includes(props.value)) {
newValue.push(props.value);
}
} else {
const index = newValue.indexOf(props.value);
if (index > -1) {
newValue.splice(index, 1);
}
}
emit("update:modelValue", newValue);
} else {
emit("update:modelValue", checked);
}
}
</script>
<template>
<Checkbox
:model-value="isChecked"
@update:model-value="handleChange"
:disabled="disabled"
:id="id"
:class="class"
/>
</template>

View File

@ -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;
}
</script>
@ -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 }}
<CheckIcon

View File

@ -0,0 +1,38 @@
<script setup>
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",
},
}
);
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>

View File

@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: {
type: String,
default: "",
},
});
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: {
type: String,
default: "",
},
});
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>

View File

@ -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";

View File

@ -0,0 +1,50 @@
<script setup>
import { computed } from "vue";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
modelValue: { type: [Boolean, Array], required: true },
value: { type: [String, Number], required: false },
disabled: { type: Boolean, default: false },
id: { type: String, required: false },
class: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue"]);
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value);
}
return props.modelValue;
});
function handleChange(checked) {
if (Array.isArray(props.modelValue)) {
const newValue = [...props.modelValue];
if (checked) {
if (!newValue.includes(props.value)) {
newValue.push(props.value);
}
} else {
const index = newValue.indexOf(props.value);
if (index > -1) {
newValue.splice(index, 1);
}
}
emit("update:modelValue", newValue);
} else {
emit("update:modelValue", checked);
}
}
</script>
<template>
<Checkbox
:model-value="isChecked"
@update:model-value="handleChange"
:disabled="disabled"
:id="id"
:class="class"
/>
</template>

View File

@ -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,
}),
{},

View File

@ -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) {
</TableActions>
</template>
</DataTable>
<ConfirmationModal
:show="confirming"
@close="
</AppCard>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<AlertDialog
:open="confirming"
@update:open="
(val) => {
if (!val) {
confirming = false;
deletingId = null;
}
}
"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Potrditev brisanja</AlertDialogTitle>
<AlertDialogDescription>
Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz
shrambe, če je še prisotna.
</AlertDialogDescription>
</AlertDialogHeader>
<p v-if="errorMsg" class="text-sm text-red-600">{{ errorMsg }}</p>
<AlertDialogFooter>
<AlertDialogCancel
@click="
confirming = false;
deletingId = null;
"
>
<template #title>Potrditev brisanja</template>
<template #content>
<p class="text-sm">
Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz
shrambe, če je še prisotna.
</p>
<p v-if="errorMsg" class="text-sm text-red-600 mt-2">{{ errorMsg }}</p>
</template>
<template #footer>
<button
class="px-3 py-1.5 text-sm border rounded me-2"
@click="
confirming = false;
deletingId = null;
"
>
Prekliči
</button>
<button
class="px-3 py-1.5 text-sm rounded bg-red-600 text-white"
@click="performDelete"
>
Izbriši
</button>
</template>
</ConfirmationModal>
</AppCard>
</div>
</div>
Prekliči
</AlertDialogCancel>
<Button @click="performDelete" class="bg-destructive hover:bg-destructive/90">
Izbriši
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppLayout>
</template>

View File

@ -1,27 +1,105 @@
<script setup>
import { Link } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link } from "@inertiajs/vue3";
import AppLayout from "@/Layouts/AppLayout.vue";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { BarChart3, FileText, TrendingUp, Activity } from "lucide-vue-next";
defineProps({
reports: { type: Array, required: true },
})
});
// Icon mapping by category
const reportIcons = {
contracts: FileText,
field: TrendingUp,
activities: Activity,
default: BarChart3,
};
function getReportIcon(category) {
return reportIcons[category] || reportIcons.default;
}
// Generate report URL with default date filters for reports that need them
function getReportUrl(slug) {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Format dates as YYYY-MM-DD
const formatDate = (date) => {
return date.toISOString().split('T')[0];
};
// Report-specific default parameters
const reportDefaults = {
'field-jobs-completed': {
from: formatDate(startOfMonth),
to: formatDate(now)
},
'decisions-counts': {
from: formatDate(startOfMonth),
to: formatDate(now)
},
'activities-per-period': {
from: formatDate(startOfMonth),
to: formatDate(now),
period: 'day'
}
};
const params = reportDefaults[slug];
if (params) {
return route('reports.show', { slug, ...params });
}
return route('reports.show', slug);
}
</script>
<template>
<AppLayout title="Poročila">
<template #header />
<div class="pt-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="mb-6">
<h1 class="text-2xl font-semibold">Poročila</h1>
<p class="text-gray-600">Izberite poročilo za pregled in izvoz.</p>
<div class="pt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Poročila</h1>
<p class="mt-2 text-sm text-muted-foreground">
Izberite poročilo za pregled in izvoz podatkov
</p>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div v-for="r in reports" :key="r.slug" class="border rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition">
<h2 class="text-lg font-medium mb-1">{{ r.name }}</h2>
<p class="text-sm text-gray-600 mb-3">{{ r.description }}</p>
<Link :href="route('reports.show', r.slug)" class="inline-flex items-center text-indigo-600 hover:underline">Odpri </Link>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card
v-for="r in reports"
:key="r.slug"
class="hover:shadow-lg transition-shadow"
>
<CardHeader>
<div class="flex items-center gap-2">
<component
:is="getReportIcon(r.category)"
class="h-5 w-5 text-muted-foreground"
/>
<CardTitle>{{ r.name }}</CardTitle>
</div>
<CardDescription>{{ r.description }}</CardDescription>
</CardHeader>
<CardContent>
<Link :href="getReportUrl(r.slug)">
<Button variant="ghost" size="sm" class="w-full justify-start">
Odpri
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
</div>

View File

@ -1,9 +1,27 @@
<script setup>
import { reactive, ref, computed, onMounted } from "vue";
import { Link } from "@inertiajs/vue3";
import { Link, router, usePage } from "@inertiajs/vue3";
import axios from "axios";
import AppLayout from "@/Layouts/AppLayout.vue";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import Pagination from "@/Components/Pagination.vue";
import DatePicker from "@/Components/DatePicker.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import InputLabel from "@/Components/InputLabel.vue";
import { Separator } from "@/Components/ui/separator";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import { Download, Filter, RotateCcw } from "lucide-vue-next";
const props = defineProps({
slug: { type: String, required: true },
@ -27,12 +45,53 @@ const filters = reactive(
)
);
const filterPopoverOpen = ref(false);
const appliedFilterCount = computed(() => {
let count = 0;
for (const inp of props.inputs || []) {
const value = filters[inp.key];
if (value !== null && value !== undefined && value !== "") {
count += 1;
}
}
return count;
});
function resetFilters() {
for (const i of props.inputs || []) {
filters[i.key] = i.default ?? null;
}
}
function applyFilters() {
filterPopoverOpen.value = false;
router.get(
route("reports.show", props.slug),
{ ...filters, per_page: props.meta?.per_page || 25, page: 1 },
{ preserveState: false, replace: false }
);
}
// If no query params were provided and we have inputs with defaults, apply filters automatically
// OR if there are validation errors for required filters, redirect with defaults
onMounted(() => {
const page = usePage();
const errors = page.props.errors || {};
const hasQueryParams = Object.keys(props.query || {}).length > 0;
const hasRequiredFilters = (props.inputs || []).some(i => i.required);
// Check if we have validation errors for required filter fields
const hasRequiredFilterErrors = (props.inputs || []).some(
i => i.required && errors[i.key]
);
if ((!hasQueryParams && hasRequiredFilters) || hasRequiredFilterErrors) {
// Apply filters with defaults on first load to satisfy required fields
applyFilters();
}
});
function exportFile(fmt) {
const params = new URLSearchParams({
format: fmt,
@ -88,11 +147,11 @@ onMounted(() => {
function formatNumberEU(val) {
if (typeof val !== "number") return String(val ?? "");
// Use 0 decimals for integers, 2 for decimals
const hasFraction = Math.abs(num % 1) > 0;
const hasFraction = Math.abs(val % 1) > 0;
const opts = hasFraction
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 };
return new Intl.NumberFormat("sl-SI", opts).format(num);
return new Intl.NumberFormat("sl-SI", opts).format(val);
}
function pad2(n) {
@ -184,128 +243,193 @@ function formatCell(value, key) {
<template>
<AppLayout :title="name">
<template #header />
<div class="pt-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">{{ name }}</h1>
<p v-if="description" class="text-gray-600">{{ description }}</p>
</div>
<div class="flex gap-2">
<button
type="button"
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="exportFile('csv')"
>
CSV
</button>
<button
type="button"
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="exportFile('pdf')"
>
PDF
</button>
<button
type="button"
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="exportFile('xlsx')"
>
Excel
</button>
</div>
</div>
<div class="mb-4 grid gap-3 md:grid-cols-4">
<div v-for="inp in inputs" :key="inp.key" class="flex flex-col">
<label class="text-sm text-gray-700 mb-1">{{ inp.label || inp.key }}</label>
<template v-if="inp.type === 'date'">
<DatePicker
v-model="filters[inp.key]"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</template>
<template v-else-if="inp.type === 'integer'">
<input
type="number"
v-model.number="filters[inp.key]"
class="border rounded px-2 py-1"
/>
</template>
<template v-else-if="inp.type === 'select:user'">
<select v-model.number="filters[inp.key]" class="border rounded px-2 py-1">
<option :value="null"> brez </option>
<option v-for="u in userOptions" :key="u.id" :value="u.id">
{{ u.name }}
</option>
</select>
<div v-if="userLoading" class="text-xs text-gray-500 mt-1">Nalagam</div>
</template>
<template v-else-if="inp.type === 'select:client'">
<select
v-model="filters[inp.key]"
class="border rounded px-2 py-1"
@change="
(e) => {
console.log('Select changed:', e.target.value, 'filters:', filters);
}
"
>
<option :value="null"> brez </option>
<option v-for="c in clientOptions" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
<div v-if="clientLoading" class="text-xs text-gray-500 mt-1">Nalagam</div>
</template>
<template v-else>
<input
type="text"
v-model="filters[inp.key]"
class="border rounded px-2 py-1"
/>
</template>
</div>
<div class="flex items-end gap-2">
<button
type="button"
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="
$inertia.get(
route('reports.show', slug),
{ ...filters, per_page: meta?.per_page || 25, page: 1 },
{ preserveState: false, replace: false }
)
"
>
Prikaži
</button>
<button
type="button"
@click="resetFilters"
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
>
Ponastavi
</button>
</div>
</div>
<div class="pt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header Card -->
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>{{ name }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="exportFile('csv')">
<Download class="mr-2 h-4 w-4" />
CSV
</Button>
<Button variant="outline" size="sm" @click="exportFile('pdf')">
<Download class="mr-2 h-4 w-4" />
PDF
</Button>
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
<Download class="mr-2 h-4 w-4" />
Excel
</Button>
</div>
</div>
</CardHeader>
</Card>
<!-- data table component -->
<DataTableServer
:columns="columns.map((c) => ({ key: c.key, label: c.label || c.key }))"
:rows="rows"
:meta="meta"
route-name="reports.show"
:route-params="{ slug: slug }"
:query="filters"
:show-toolbar="false"
:only-props="['rows', 'meta', 'query']"
:preserve-state="false"
<!-- Results Card -->
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #cell="{ value, column }">
{{ formatCell(value, column.key) }}
<template #header>
<div class="flex items-center justify-between w-full">
<CardTitle>Rezultati</CardTitle>
<CardDescription>
Skupaj {{ meta?.total || 0 }}
{{ meta?.total === 1 ? "rezultat" : "rezultatov" }}
</CardDescription>
</div>
</template>
</DataTableServer>
<DataTable
:columns="
columns.map((c) => ({
key: c.key,
label: c.label || c.key,
sortable: false,
}))
"
:data="rows"
:meta="meta"
route-name="reports.show"
:route-params="{ slug: slug, ...filters }"
:page-size="meta?.per_page || 25"
:page-size-options="[10, 25, 50, 100]"
:show-pagination="false"
:show-toolbar="true"
:hoverable="true"
empty-text="Ni rezultatov."
>
<template #toolbar-filters>
<AppPopover
v-if="inputs && inputs.length > 0"
v-model:open="filterPopoverOpen"
align="start"
content-class="w-[400px]"
>
<template #trigger>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="appliedFilterCount > 0"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri poročila</h4>
<p class="text-sm text-muted-foreground">
Izberite parametre za zožanje prikaza podatkov.
</p>
</div>
<div class="space-y-3">
<div v-for="inp in inputs" :key="inp.key" class="space-y-1.5">
<InputLabel>{{ inp.label || inp.key }}</InputLabel>
<template v-if="inp.type === 'date'">
<DatePicker
v-model="filters[inp.key]"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</template>
<template v-else-if="inp.type === 'integer'">
<Input
v-model.number="filters[inp.key]"
type="number"
placeholder="Vnesi število"
/>
</template>
<template v-else-if="inp.type === 'select:user'">
<AppCombobox
v-model="filters[inp.key]"
:items="
userOptions.map((u) => ({ value: u.id, label: u.name }))
"
placeholder="Brez"
search-placeholder="Išči uporabnika..."
empty-text="Ni uporabnikov"
:disabled="userLoading"
button-class="w-full"
/>
<div v-if="userLoading" class="text-xs text-muted-foreground">
Nalagam
</div>
</template>
<template v-else-if="inp.type === 'select:client'">
<AppCombobox
v-model="filters[inp.key]"
:items="
clientOptions.map((c) => ({ value: c.id, label: c.name }))
"
placeholder="Brez"
search-placeholder="Išči stranko..."
empty-text="Ni strank"
:disabled="clientLoading"
button-class="w-full"
/>
<div v-if="clientLoading" class="text-xs text-muted-foreground">
Nalagam
</div>
</template>
<template v-else>
<Input
v-model="filters[inp.key]"
type="text"
placeholder="Vnesi vrednost"
/>
</template>
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="appliedFilterCount === 0"
@click="resetFilters"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</div>
</AppPopover>
</template>
<template
v-for="col in columns"
:key="col.key"
#[`cell-${col.key}`]="{ row }"
>
{{ formatCell(row[col.key], col.key) }}
</template>
</DataTable>
<div class="border-t border-gray-200 p-4">
<Pagination
:links="meta.links"
:from="meta.from"
:to="meta.to"
:total="meta.total"
:per-page="meta.per_page || 25"
:last-page="meta.last_page"
:current-page="meta.current_page"
per-page-param="per_page"
page-param="page"
/>
</div>
</AppCard>
</div>
</div>
</AppLayout>

View File

@ -1,7 +1,14 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Alert, AlertDescription, AlertTitle } from "@/Components/ui/alert";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Archive } from "lucide-vue-next";
import ArchiveRuleCard from "./Partials/ArchiveRuleCard.vue";
import CreateRuleForm from "./Partials/CreateRuleForm.vue";
import EditRuleForm from "./Partials/EditRuleForm.vue";
const props = defineProps({
settings: Object,
@ -29,7 +36,6 @@ const newForm = useForm({
// Editing state & form
const editingSetting = ref(null);
// Conditions temporarily inactive in backend; keep placeholder for future restore
const originalEntityMeta = ref({ columns: ["id"] });
const editForm = useForm({
name: "",
@ -47,14 +53,6 @@ const editForm = useForm({
options: { batch_size: 200 },
});
const selectedEntity = ref(null);
function onFocusChange() {
const found = props.archiveEntities.find((e) => e.focus === newForm.focus);
selectedEntity.value = found || null;
newForm.related = [];
}
function submitCreate() {
if (!newForm.focus) {
alert("Select a focus entity.");
@ -68,11 +66,11 @@ function submitCreate() {
{
table: newForm.focus,
related: newForm.related,
// conditions omitted while inactive
columns: ["id"],
},
];
newForm.post(route("settings.archive.store"), {
preserveScroll: true,
onSuccess: () => {
newForm.focus = "";
newForm.related = [];
@ -80,21 +78,26 @@ function submitCreate() {
newForm.action_id = null;
newForm.decision_id = null;
newForm.segment_id = null;
selectedEntity.value = null;
newForm.reset();
},
});
}
function toggleEnabled(setting) {
router.put(route("settings.archive.update", setting.id), {
...setting,
enabled: !setting.enabled,
});
router.put(
route("settings.archive.update", setting.id),
{
...setting,
enabled: !setting.enabled,
},
{
preserveScroll: true,
}
);
}
function startEdit(setting) {
editingSetting.value = setting;
// Populate editForm
editForm.name = setting.name || "";
editForm.description = setting.description || "";
editForm.enabled = setting.enabled;
@ -104,7 +107,7 @@ function startEdit(setting) {
editForm.action_id = setting.action_id ?? null;
editForm.decision_id = setting.decision_id ?? null;
editForm.segment_id = setting.segment_id ?? null;
// Entities (first only)
const first = Array.isArray(setting.entities) ? setting.entities[0] : null;
if (first) {
editForm.focus = first.table || "";
@ -112,20 +115,16 @@ function startEdit(setting) {
originalEntityMeta.value = {
columns: first.columns || ["id"],
};
const found = props.archiveEntities.find((e) => e.focus === editForm.focus);
selectedEntity.value = found || null;
} else {
editForm.focus = "";
editForm.related = [];
originalEntityMeta.value = { columns: ["id"] };
// If reactivate is checked it implies soft semantics; keep soft true (UI might show both)
}
}
function cancelEdit() {
editingSetting.value = null;
editForm.reset();
selectedEntity.value = null;
}
function submitUpdate() {
@ -142,11 +141,11 @@ function submitUpdate() {
{
table: editForm.focus,
related: editForm.related,
// conditions omitted while inactive
columns: originalEntityMeta.value.columns || ["id"],
},
];
editForm.put(route("settings.archive.update", editingSetting.value.id), {
preserveScroll: true,
onSuccess: () => {
cancelEdit();
},
@ -155,427 +154,93 @@ function submitUpdate() {
function remove(setting) {
if (!confirm("Delete archive rule?")) return;
router.delete(route("settings.archive.destroy", setting.id));
router.delete(route("settings.archive.destroy", setting.id), {
preserveScroll: true,
});
}
// Run Now removed (feature temporarily disabled)
</script>
<template>
<AppLayout title="Archive Settings">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Archive Settings</h2>
</template>
<template #header />
<div class="pt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Alert variant="default" class="mb-6 border-l-4 border-amber-500">
<AlertTitle class="text-sm font-medium text-amber-800">
Archive rule conditions are temporarily inactive
</AlertTitle>
<AlertDescription class="text-xs text-amber-800 space-y-2 mt-2">
<p>
All enabled rules apply to the focus entity and its selected related tables
without date/other filters. Stored condition JSON is preserved for future
reactivation.
</p>
<p class="font-medium">The "Run Now" action is currently disabled.</p>
<div class="mt-3 bg-white/60 rounded p-3 border border-amber-200">
<p class="font-semibold mb-1 text-amber-900">Chain Path Help</p>
<p class="mb-1">Supported chained related tables (dot notation):</p>
<ul class="list-disc ml-4 space-y-0.5">
<li v-for="cp in chainPatterns" :key="cp">
<code class="px-1 bg-amber-100 rounded text-xs">{{ cp }}</code>
</li>
</ul>
<p class="mt-1 italic">Only these chains are processed; others are ignored.</p>
</div>
</AlertDescription>
</Alert>
<div class="py-6 max-w-6xl mx-auto px-4">
<div class="mb-6 border-l-4 border-amber-500 bg-amber-50 text-amber-800 px-4 py-3 rounded">
<p class="text-sm font-medium">Archive rule conditions are temporarily inactive.</p>
<p class="text-xs mt-1">All enabled rules apply to the focus entity and its selected related tables without date/other filters. Stored condition JSON is preserved for future reactivation.</p>
<p class="text-xs mt-1 font-medium">The "Run Now" action is currently disabled.</p>
<div class="mt-3 text-xs bg-white/60 rounded p-3 border border-amber-200">
<p class="font-semibold mb-1 text-amber-900">Chain Path Help</p>
<p class="mb-1">Supported chained related tables (dot notation):</p>
<ul class="list-disc ml-4 space-y-0.5">
<li v-for="cp in chainPatterns" :key="cp">
<code class="px-1 bg-amber-100 rounded">{{ cp }}</code>
</li>
</ul>
<p class="mt-1 italic">Only these chains are processed; others are ignored.</p>
</div>
</div>
<div class="grid gap-6 md:grid-cols-3">
<div class="md:col-span-2 space-y-4">
<div
v-for="s in settings.data"
:key="s.id"
class="border rounded-lg p-4 bg-white shadow-sm"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h3 class="font-medium text-gray-900 flex items-center gap-2">
<span class="truncate">{{ s.name || "Untitled Rule #" + s.id }}</span>
<span
v-if="!s.enabled"
class="inline-flex text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800"
>Disabled</span
>
</h3>
<p v-if="s.description" class="text-sm text-gray-600 mt-1">
{{ s.description }}
</p>
<p class="mt-2 text-xs text-gray-500">
Strategy: {{ s.strategy }} Soft: {{ s.soft ? "Yes" : "No" }}
</p>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<button
@click="startEdit(s)"
class="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-800 hover:bg-gray-300"
>
Edit
</button>
<!-- Run Now removed -->
<button
@click="toggleEnabled(s)"
class="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700"
>
{{ s.enabled ? "Disable" : "Enable" }}
</button>
<button
@click="remove(s)"
class="text-xs px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700"
>
Delete
</button>
</div>
</div>
<div class="mt-3 text-xs bg-gray-50 border rounded p-2 overflow-x-auto">
<pre class="whitespace-pre-wrap">{{
JSON.stringify(s.entities, null, 2)
}}</pre>
</div>
</div>
<div v-if="!settings.data.length" class="text-sm text-gray-600">
No archive rules.
</div>
</div>
<div class="space-y-4">
<div v-if="!editingSetting" class="border rounded-lg p-4 bg-white shadow-sm">
<h3 class="font-semibold text-gray-900 mb-2 text-sm">New Rule</h3>
<div class="space-y-3 text-sm">
<div>
<label class="block text-xs font-medium text-gray-600"
>Segment (optional)</label
>
<select
v-model="newForm.segment_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="seg in segments" :key="seg.id" :value="seg.id">
{{ seg.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Action (optional)</label
>
<select
v-model="newForm.action_id"
@change="
() => {
newForm.decision_id = null;
}
"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Decision (optional)</label
>
<select
v-model="newForm.decision_id"
:disabled="!newForm.action_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option
v-for="d in actions.find((a) => a.id === newForm.action_id)
?.decisions || []"
:key="d.id"
:value="d.id"
>
{{ d.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Name</label>
<input
v-model="newForm.name"
type="text"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
<div class="grid gap-6 md:grid-cols-3">
<div class="md:col-span-2 space-y-4">
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
>
<template #header>
<div class="flex items-center gap-2">
<Archive :size="18" />
<CardTitle class="uppercase">Archive Rules</CardTitle>
</div>
</template>
<div class="p-4 space-y-4">
<ArchiveRuleCard
v-for="rule in settings.data"
:key="rule.id"
:rule="rule"
@edit="startEdit"
@toggle-enabled="toggleEnabled"
@delete="remove"
/>
<div v-if="newForm.errors.name" class="text-red-600 text-xs mt-1">
{{ newForm.errors.name }}
<div
v-if="!settings.data.length"
class="text-sm text-muted-foreground text-center py-8"
>
No archive rules defined yet.
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Focus Entity</label
>
<select
v-model="newForm.focus"
@change="onFocusChange"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="" disabled>-- choose --</option>
<option v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
{{ ae.name || ae.focus }}
</option>
</select>
</div>
<div v-if="selectedEntity" class="space-y-1">
<div class="text-xs font-medium text-gray-600">Related Tables</div>
<div class="flex flex-wrap gap-2">
<label
v-for="r in selectedEntity.related"
:key="r"
class="inline-flex items-center gap-1 text-xs bg-gray-100 px-2 py-1 rounded border"
>
<input
type="checkbox"
:value="r"
v-model="newForm.related"
class="rounded"
/>
<span>{{ r }}</span>
</label>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Description</label>
<textarea
v-model="newForm.description"
rows="2"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
></textarea>
<div v-if="newForm.errors.description" class="text-red-600 text-xs mt-1">
{{ newForm.errors.description }}
</div>
</div>
<div class="flex items-center gap-2">
<input id="enabled" type="checkbox" v-model="newForm.enabled" />
<label for="enabled" class="text-xs font-medium text-gray-700"
>Enabled</label
>
</div>
<div class="flex items-center gap-2">
<input id="soft" type="checkbox" v-model="newForm.soft" />
<label for="soft" class="text-xs font-medium text-gray-700"
>Soft Archive</label
>
</div>
<div class="flex items-center gap-2">
<input id="reactivate" type="checkbox" v-model="newForm.reactivate" />
<label for="reactivate" class="text-xs font-medium text-gray-700"
>Reactivate (undo archive)</label
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Strategy</label>
<select
v-model="newForm.strategy"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="immediate">Immediate</option>
<option value="scheduled">Scheduled</option>
<option value="queued">Queued</option>
<option value="manual">Manual (never auto-run)</option>
</select>
<div v-if="newForm.errors.strategy" class="text-red-600 text-xs mt-1">
{{ newForm.errors.strategy }}
</div>
</div>
<button
@click="submitCreate"
type="button"
:disabled="newForm.processing"
class="w-full text-sm px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
>
Create
</button>
<div v-if="Object.keys(newForm.errors).length" class="text-xs text-red-600">
Please fix validation errors.
</div>
</div>
</AppCard>
</div>
<div v-else class="border rounded-lg p-4 bg-white shadow-sm">
<h3 class="font-semibold text-gray-900 mb-2 text-sm">
Edit Rule #{{ editingSetting.id }}
</h3>
<div class="space-y-3 text-sm">
<div
class="text-xs text-gray-500"
v-if="editingSetting.strategy === 'manual'"
>
Manual strategy: this rule will only run when triggered manually.
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Segment (optional)</label
>
<select
v-model="editForm.segment_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="seg in segments" :key="seg.id" :value="seg.id">
{{ seg.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Action (optional)</label
>
<select
v-model="editForm.action_id"
@change="
() => {
editForm.decision_id = null;
}
"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Decision (optional)</label
>
<select
v-model="editForm.decision_id"
:disabled="!editForm.action_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option
v-for="d in actions.find((a) => a.id === editForm.action_id)
?.decisions || []"
:key="d.id"
:value="d.id"
>
{{ d.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Name</label>
<input
v-model="editForm.name"
type="text"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
/>
<div v-if="editForm.errors.name" class="text-red-600 text-xs mt-1">
{{ editForm.errors.name }}
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Focus Entity</label
>
<select
v-model="editForm.focus"
@change="onFocusChange() /* reuse selectedEntity for preview */"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="" disabled>-- choose --</option>
<option v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
{{ ae.name || ae.focus }}
</option>
</select>
</div>
<div
v-if="selectedEntity && editForm.focus === selectedEntity.focus"
class="space-y-1"
>
<div class="text-xs font-medium text-gray-600">Related Tables</div>
<div class="flex flex-wrap gap-2">
<label
v-for="r in selectedEntity.related"
:key="r"
class="inline-flex items-center gap-1 text-xs bg-gray-100 px-2 py-1 rounded border"
>
<input
type="checkbox"
:value="r"
v-model="editForm.related"
class="rounded"
/>
<span>{{ r }}</span>
</label>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Description</label>
<textarea
v-model="editForm.description"
rows="2"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
></textarea>
<div v-if="editForm.errors.description" class="text-red-600 text-xs mt-1">
{{ editForm.errors.description }}
</div>
</div>
<div class="flex items-center gap-2">
<input id="edit_enabled" type="checkbox" v-model="editForm.enabled" />
<label for="edit_enabled" class="text-xs font-medium text-gray-700"
>Enabled</label
>
</div>
<div class="flex items-center gap-2">
<input id="edit_soft" type="checkbox" v-model="editForm.soft" />
<label for="edit_soft" class="text-xs font-medium text-gray-700"
>Soft Archive</label
>
</div>
<div class="flex items-center gap-2">
<input id="edit_reactivate" type="checkbox" v-model="editForm.reactivate" />
<label for="edit_reactivate" class="text-xs font-medium text-gray-700"
>Reactivate (undo archive)</label
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Strategy</label>
<select
v-model="editForm.strategy"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="immediate">Immediate</option>
<option value="scheduled">Scheduled</option>
<option value="queued">Queued</option>
<option value="manual">Manual (never auto-run)</option>
</select>
<div v-if="editForm.errors.strategy" class="text-red-600 text-xs mt-1">
{{ editForm.errors.strategy }}
</div>
</div>
<div class="flex gap-2">
<button
@click="submitUpdate"
type="button"
:disabled="editForm.processing"
class="flex-1 text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
>
Update
</button>
<button
@click="cancelEdit"
type="button"
class="px-3 py-2 rounded text-sm bg-gray-200 hover:bg-gray-300"
>
Cancel
</button>
</div>
<div
v-if="Object.keys(editForm.errors).length"
class="text-xs text-red-600"
>
Please fix validation errors.
</div>
</div>
<div>
<CreateRuleForm
v-if="!editingSetting"
:form="newForm"
:archive-entities="archiveEntities"
:actions="actions"
:segments="segments"
@submit="submitCreate"
/>
<EditRuleForm
v-else
:form="editForm"
:setting="editingSetting"
:archive-entities="archiveEntities"
:actions="actions"
:segments="segments"
@submit="submitUpdate"
@cancel="cancelEdit"
/>
</div>
</div>
</div>

View File

@ -0,0 +1,72 @@
<script setup>
import { Button } from "@/Components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { MoreHorizontal, Pencil, Trash, Power, PowerOff } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
defineProps({
rule: Object,
});
const emit = defineEmits(["edit", "toggle-enabled", "delete"]);
</script>
<template>
<div class="border rounded-lg p-4 bg-white shadow-sm">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-gray-900 truncate">
{{ rule.name || "Untitled Rule #" + rule.id }}
</h3>
<Badge v-if="!rule.enabled" variant="secondary" class="text-xs">
Disabled
</Badge>
</div>
<p v-if="rule.description" class="text-sm text-muted-foreground mt-1">
{{ rule.description }}
</p>
<div class="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span>Strategy: <span class="font-medium">{{ rule.strategy }}</span></span>
<span></span>
<span>Soft: <span class="font-medium">{{ rule.soft ? "Yes" : "No" }}</span></span>
<span v-if="rule.reactivate" class="text-amber-600 font-medium">
Reactivate Mode
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="emit('edit', rule)">
<Pencil class="w-4 h-4 mr-2" />
Uredi
</DropdownMenuItem>
<DropdownMenuItem @click="emit('toggle-enabled', rule)">
<component :is="rule.enabled ? PowerOff : Power" class="w-4 h-4 mr-2" />
{{ rule.enabled ? "Disable" : "Enable" }}
</DropdownMenuItem>
<DropdownMenuItem
@click="emit('delete', rule)"
class="text-red-600 focus:text-red-600"
>
<Trash class="w-4 h-4 mr-2" />
Izbriši
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="mt-3 text-xs bg-muted rounded p-3 overflow-x-auto">
<pre class="whitespace-pre-wrap">{{ JSON.stringify(rule.entities, null, 2) }}</pre>
</div>
</div>
</template>

View File

@ -0,0 +1,184 @@
<script setup>
import { Button } from "@/Components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import AppCheckboxArray from "@/Components/app/ui/AppCheckboxArray.vue";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import InputLabel from "@/Components/InputLabel.vue";
import InputError from "@/Components/InputError.vue";
import { ref, computed } from "vue";
const props = defineProps({
form: Object,
archiveEntities: Array,
actions: Array,
segments: Array,
});
const emit = defineEmits(["submit"]);
const selectedEntity = ref(null);
function onFocusChange() {
const found = props.archiveEntities.find((e) => e.focus === props.form.focus);
selectedEntity.value = found || null;
props.form.related = [];
}
const availableDecisions = computed(() => {
if (!props.form.action_id) return [];
const action = props.actions.find((a) => a.id === props.form.action_id);
return action?.decisions || [];
});
function handleActionChange() {
props.form.decision_id = null;
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle class="text-base">New Archive Rule</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div>
<InputLabel for="new_segment">Segment (optional)</InputLabel>
<Select v-model="form.segment_id">
<SelectTrigger id="new_segment" class="w-full">
<SelectValue placeholder="-- none --" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">-- none --</SelectItem>
<SelectItem v-for="seg in segments" :key="seg.id" :value="seg.id">
{{ seg.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="new_action">Action (optional)</InputLabel>
<Select v-model="form.action_id" @update:model-value="handleActionChange">
<SelectTrigger id="new_action" class="w-full">
<SelectValue placeholder="-- none --" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">-- none --</SelectItem>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="new_decision">Decision (optional)</InputLabel>
<Select v-model="form.decision_id" :disabled="!form.action_id">
<SelectTrigger id="new_decision" class="w-full">
<SelectValue placeholder="-- none --" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">-- none --</SelectItem>
<SelectItem v-for="d in availableDecisions" :key="d.id" :value="d.id">
{{ d.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="new_name">Name</InputLabel>
<Input id="new_name" v-model="form.name" type="text" />
<InputError :message="form.errors.name" class="mt-1" />
</div>
<div>
<InputLabel for="new_focus">Focus Entity</InputLabel>
<Select v-model="form.focus" @update:model-value="onFocusChange">
<SelectTrigger id="new_focus" class="w-full">
<SelectValue placeholder="-- choose --" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
{{ ae.name || ae.focus }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="selectedEntity" class="space-y-2">
<InputLabel>Related Tables</InputLabel>
<div class="flex flex-wrap gap-2">
<label
v-for="r in selectedEntity.related"
:key="r"
class="inline-flex items-center gap-2 text-sm bg-muted px-3 py-1.5 rounded-md border cursor-pointer hover:bg-muted/80"
>
<AppCheckboxArray :value="r" v-model="form.related" />
<span>{{ r }}</span>
</label>
</div>
</div>
<div>
<InputLabel for="new_description">Description</InputLabel>
<Textarea id="new_description" v-model="form.description" rows="2" />
<InputError :message="form.errors.description" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<Checkbox id="new_enabled" v-model="form.enabled" />
<InputLabel for="new_enabled" class="text-sm font-normal cursor-pointer">
Enabled
</InputLabel>
</div>
<div class="flex items-center gap-2">
<Checkbox id="new_soft" v-model="form.soft" />
<InputLabel for="new_soft" class="text-sm font-normal cursor-pointer">
Soft Archive
</InputLabel>
</div>
<div class="flex items-center gap-2">
<Checkbox id="new_reactivate" v-model="form.reactivate" />
<InputLabel for="new_reactivate" class="text-sm font-normal cursor-pointer">
Reactivate (undo archive)
</InputLabel>
</div>
<div>
<InputLabel for="new_strategy">Strategy</InputLabel>
<Select v-model="form.strategy">
<SelectTrigger id="new_strategy" class="w-full">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
<SelectItem value="queued">Queued</SelectItem>
<SelectItem value="manual">Manual (never auto-run)</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.strategy" class="mt-1" />
</div>
<Button @click="emit('submit')" :disabled="form.processing" class="w-full">
Create Rule
</Button>
<div v-if="Object.keys(form.errors).length" class="text-xs text-red-600">
Please fix validation errors.
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,199 @@
<script setup>
import { Button } from "@/Components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import AppCheckboxArray from "@/Components/app/ui/AppCheckboxArray.vue";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Alert, AlertDescription } from "@/Components/ui/alert";
import InputLabel from "@/Components/InputLabel.vue";
import InputError from "@/Components/InputError.vue";
import { ref, computed, watch } from "vue";
const props = defineProps({
form: Object,
setting: Object,
archiveEntities: Array,
actions: Array,
segments: Array,
});
const emit = defineEmits(["submit", "cancel"]);
const selectedEntity = ref(null);
// Initialize selectedEntity based on form.focus
watch(
() => props.form.focus,
(newFocus) => {
const found = props.archiveEntities.find((e) => e.focus === newFocus);
selectedEntity.value = found || null;
},
{ immediate: true }
);
const availableDecisions = computed(() => {
if (!props.form.action_id) return [];
const action = props.actions.find((a) => a.id === props.form.action_id);
return action?.decisions || [];
});
function handleActionChange() {
props.form.decision_id = null;
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle class="text-base">Edit Rule #{{ setting.id }}</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<Alert v-if="setting.strategy === 'manual'" variant="default">
<AlertDescription class="text-xs">
Manual strategy: this rule will only run when triggered manually.
</AlertDescription>
</Alert>
<div>
<InputLabel for="edit_segment">Segment (optional)</InputLabel>
<Select v-model="form.segment_id">
<SelectTrigger id="edit_segment" class="w-full">
<SelectValue placeholder="-- none --" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">-- none --</SelectItem>
<SelectItem v-for="seg in segments" :key="seg.id" :value="seg.id">
{{ seg.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="edit_action">Action (optional)</InputLabel>
<Select v-model="form.action_id" @update:model-value="handleActionChange">
<SelectTrigger id="edit_action" class="w-full">
<SelectValue placeholder="-- none --" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">-- none --</SelectItem>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="edit_decision">Decision (optional)</InputLabel>
<Select v-model="form.decision_id" :disabled="!form.action_id">
<SelectTrigger id="edit_decision" class="w-full">
<SelectValue placeholder="-- none --" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">-- none --</SelectItem>
<SelectItem v-for="d in availableDecisions" :key="d.id" :value="d.id">
{{ d.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="edit_name">Name</InputLabel>
<Input id="edit_name" v-model="form.name" type="text" />
<InputError :message="form.errors.name" class="mt-1" />
</div>
<div>
<InputLabel for="edit_focus">Focus Entity</InputLabel>
<Select v-model="form.focus">
<SelectTrigger id="edit_focus" class="w-full">
<SelectValue placeholder="-- choose --" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
{{ ae.name || ae.focus }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="selectedEntity && form.focus === selectedEntity.focus" class="space-y-2">
<InputLabel>Related Tables</InputLabel>
<div class="flex flex-wrap gap-2">
<label
v-for="r in selectedEntity.related"
:key="r"
class="inline-flex items-center gap-2 text-sm bg-muted px-3 py-1.5 rounded-md border cursor-pointer hover:bg-muted/80"
>
<AppCheckboxArray :value="r" v-model="form.related" />
<span>{{ r }}</span>
</label>
</div>
</div>
<div>
<InputLabel for="edit_description">Description</InputLabel>
<Textarea id="edit_description" v-model="form.description" rows="2" />
<InputError :message="form.errors.description" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<Checkbox id="edit_enabled" v-model="form.enabled" />
<InputLabel for="edit_enabled" class="text-sm font-normal cursor-pointer">
Enabled
</InputLabel>
</div>
<div class="flex items-center gap-2">
<Checkbox id="edit_soft" v-model="form.soft" />
<InputLabel for="edit_soft" class="text-sm font-normal cursor-pointer">
Soft Archive
</InputLabel>
</div>
<div class="flex items-center gap-2">
<Checkbox id="edit_reactivate" v-model="form.reactivate" />
<InputLabel for="edit_reactivate" class="text-sm font-normal cursor-pointer">
Reactivate (undo archive)
</InputLabel>
</div>
<div>
<InputLabel for="edit_strategy">Strategy</InputLabel>
<Select v-model="form.strategy">
<SelectTrigger id="edit_strategy" class="w-full">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
<SelectItem value="queued">Queued</SelectItem>
<SelectItem value="manual">Manual (never auto-run)</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.strategy" class="mt-1" />
</div>
<div class="flex gap-2">
<Button @click="emit('submit')" :disabled="form.processing" class="flex-1">
Update
</Button>
<Button @click="emit('cancel')" variant="outline"> Cancel </Button>
</div>
<div v-if="Object.keys(form.errors).length" class="text-xs text-red-600">
Please fix validation errors.
</div>
</CardContent>
</Card>
</template>

View File

@ -1,58 +1,141 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import SectionTitle from '@/Components/SectionTitle.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'
import ConfirmationModal from '@/Components/ConfirmationModal.vue'
import InputLabel from '@/Components/InputLabel.vue'
import InputError from '@/Components/InputError.vue'
import { useForm, router } from '@inertiajs/vue3'
import { ref } from 'vue'
import AppLayout from "@/Layouts/AppLayout.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Button } from "@/Components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogFooter,
} from "@/Components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import InputLabel from "@/Components/InputLabel.vue";
import InputError from "@/Components/InputError.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { useForm, router } from "@inertiajs/vue3";
import { ref, nextTick } from "vue";
import {
MoreHorizontal,
Pencil,
Trash,
FileText,
Badge as BadgeIcon,
Check as CheckIcon,
} from "lucide-vue-next";
const props = defineProps({
configs: Array,
types: Array,
segments: Array,
})
});
// create modal
const showCreate = ref(false)
const openCreate = () => { showCreate.value = true }
const closeCreate = () => { showCreate.value = false; createForm.reset() }
const createForm = useForm({ contract_type_id: null, segment_id: null, is_initial: false })
const showCreate = ref(false);
const openCreate = () => {
showCreate.value = true;
};
const closeCreate = () => {
showCreate.value = false;
createForm.reset();
};
const createForm = useForm({
contract_type_id: null,
segment_id: null,
is_initial: false,
});
const submitCreate = () => {
createForm.post(route('settings.contractConfigs.store'), {
createForm.post(route("settings.contractConfigs.store"), {
preserveScroll: true,
onSuccess: () => closeCreate(),
})
}
});
};
// inline edit
const editing = ref(null)
const editForm = useForm({ segment_id: null, is_initial: false, active: true })
const openEdit = (row) => { editing.value = row; editForm.segment_id = row?.segment_id ?? row?.segment?.id; editForm.is_initial = !!row.is_initial; editForm.active = !!row.active }
const closeEdit = () => { editing.value = null }
const showEdit = ref(false);
const editing = ref(null);
const editFormIsInitial = ref(false);
const editFormActive = ref(false);
const editForm = useForm({ segment_id: null, is_initial: false, active: false });
const openEdit = (row) => {
editing.value = row;
editForm.clearErrors();
// Set values
editForm.segment_id = row?.segment_id ?? row?.segment?.id ?? null;
editFormIsInitial.value = row.is_initial === 1 || row.is_initial === true;
editFormActive.value = row.active === 1 || row.active === true;
showEdit.value = true;
};
const closeEdit = () => {
showEdit.value = false;
editing.value = null;
editFormIsInitial.value = false;
editFormActive.value = false;
editForm.reset();
editForm.clearErrors();
};
const submitEdit = () => {
if (!editing.value) return
editForm.put(route('settings.contractConfigs.update', editing.value.id), {
if (!editing.value) return;
editForm.is_initial = editFormIsInitial.value;
editForm.active = editFormActive.value;
editForm.put(route("settings.contractConfigs.update", editing.value.id), {
preserveScroll: true,
onSuccess: () => closeEdit(),
})
}
});
};
// delete confirmation
const showDelete = ref(false)
const toDelete = ref(null)
const confirmDelete = (row) => { toDelete.value = row; showDelete.value = true }
const cancelDelete = () => { toDelete.value = null; showDelete.value = false }
const showDelete = ref(false);
const toDelete = ref(null);
const confirmDelete = (row) => {
toDelete.value = row;
showDelete.value = true;
};
const cancelDelete = () => {
toDelete.value = null;
showDelete.value = false;
};
const destroyConfig = () => {
if (!toDelete.value) return
router.delete(route('settings.contractConfigs.destroy', toDelete.value.id), {
if (!toDelete.value) return;
router.delete(route("settings.contractConfigs.destroy", toDelete.value.id), {
preserveScroll: true,
onFinish: () => cancelDelete(),
})
}
});
};
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "ID", sortable: true, class: "w-16" },
{ key: "type", label: "Type", sortable: false },
{ key: "segment", label: "Segment", sortable: false },
{ key: "active", label: "Active", sortable: false, class: "w-24" },
];
</script>
<template>
@ -60,116 +143,195 @@ const destroyConfig = () => {
<template #header />
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-indigo-400">
<div class="p-4 flex items-center justify-between">
<SectionTitle>
<template #title>Contract configurations</template>
</SectionTitle>
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
</div>
<div class="px-4 pb-4">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Type</th>
<th class="py-2 pr-4">Segment</th>
<th class="py-2 pr-4">Active</th>
<th class="py-2 pr-4 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="cfg in configs" :key="cfg.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ cfg.type?.name }}</td>
<td class="py-2 pr-4">{{ cfg.segment?.name }} <span v-if="cfg.is_initial" class="ml-2 text-xs text-indigo-600">(initial)</span></td>
<td class="py-2 pr-4">{{ cfg.active ? 'Yes' : 'No' }}</td>
<td class="py-2 pr-4 text-right">
<button class="px-2 py-1 text-indigo-600 hover:underline" @click="openEdit(cfg)">Edit</button>
<button class="ml-2 px-2 py-1 text-red-600 hover:underline" @click="confirmDelete(cfg)">Delete</button>
</td>
</tr>
<tr v-if="!configs || configs.length === 0">
<td colspan="4" class="py-6 text-center text-gray-500">No configurations.</td>
</tr>
</tbody>
</table>
</div>
</div>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<FileText :size="18" />
<CardTitle class="uppercase">Contract configurations</CardTitle>
</div>
<Button @click="openCreate">+ New</Button>
</div>
</template>
<DataTableClient
:columns="columns"
:rows="configs"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-type="{ row }">
{{ row.type?.name }}
</template>
<template #cell-segment="{ row }">
<div class="flex items-center gap-2">
{{ row.segment?.name }}
<CheckIcon v-if="row.is_initial" class="w-5 h-5 text-primary" />
</div>
</template>
<template #cell-active="{ row }">
<div class="flex items-center">
<div
class="w-3 h-3 rounded-full"
:class="row.active ? 'bg-green-500' : 'bg-red-500'"
></div>
</div>
</template>
<template #actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openEdit(row)">
<Pencil class="w-4 h-4 mr-2" />
Uredi
</DropdownMenuItem>
<DropdownMenuItem
@click="confirmDelete(row)"
class="text-red-600 focus:text-red-600"
>
<Trash class="w-4 h-4 mr-2" />
Izbriši
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableClient>
</AppCard>
</div>
</div>
<!-- create modal -->
<CreateDialog
:show="showCreate"
title="New Contract Configuration"
confirm-text="Create"
:processing="createForm.processing"
:disabled="!createForm.contract_type_id || !createForm.segment_id"
@close="closeCreate"
@confirm="submitCreate"
>
<div class="space-y-4">
<Dialog v-model:open="showCreate">
<DialogContent class="max-w-xl">
<DialogHeader>
<DialogTitle>New Contract Configuration</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="type">Contract Type</InputLabel>
<select id="type" v-model="createForm.contract_type_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null" disabled>-- select type --</option>
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
<InputError :message="createForm.errors.contract_type_id" />
<Select v-model="createForm.contract_type_id">
<SelectTrigger id="type" class="w-full">
<SelectValue placeholder="-- select type --" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="t in types" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="createForm.errors.contract_type_id" class="mt-1" />
</div>
<div>
<InputLabel for="segment">Segment</InputLabel>
<select id="segment" v-model="createForm.segment_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null" disabled>-- select segment --</option>
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<InputError :message="createForm.errors.segment_id" />
<Select v-model="createForm.segment_id">
<SelectTrigger id="segment" class="w-full">
<SelectValue placeholder="-- select segment --" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="createForm.errors.segment_id" class="mt-1" />
<div class="mt-3 flex items-center gap-2">
<input id="is_initial" type="checkbox" v-model="createForm.is_initial" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_initial" class="text-sm text-gray-700">Mark as initial</label>
<Checkbox id="is_initial" v-model="createForm.is_initial" />
<InputLabel for="is_initial" class="text-sm font-normal cursor-pointer"
>Mark as initial</InputLabel
>
</div>
</div>
</div>
</CreateDialog>
</div>
<DialogFooter>
<Button variant="outline" @click="closeCreate">Cancel</Button>
<Button
@click="submitCreate"
:disabled="
createForm.processing ||
!createForm.contract_type_id ||
!createForm.segment_id
"
>Create</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- simple inline edit dialog -->
<UpdateDialog
:show="!!editing"
title="Edit Configuration"
confirm-text="Save"
:processing="editForm.processing"
:disabled="!editForm.segment_id"
@close="closeEdit"
@confirm="submitEdit"
>
<div class="space-y-4">
<Dialog v-model:open="showEdit">
<DialogContent class="max-w-xl">
<DialogHeader>
<DialogTitle>Edit Configuration</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel>Segment</InputLabel>
<select v-model="editForm.segment_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<InputError :message="editForm.errors.segment_id" />
<Select v-model="editForm.segment_id">
<SelectTrigger class="w-full">
<SelectValue placeholder="-- select segment --" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="editForm.errors.segment_id" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<input id="is_initial_edit" type="checkbox" v-model="editForm.is_initial" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_initial_edit" class="text-sm text-gray-700">Initial</label>
<Checkbox id="is_initial_edit" v-model="editFormIsInitial" />
<InputLabel for="is_initial_edit" class="text-sm font-normal cursor-pointer"
>Initial</InputLabel
>
</div>
<div class="flex items-center gap-2">
<input id="active" type="checkbox" v-model="editForm.active" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="active" class="text-sm text-gray-700">Active</label>
<Checkbox id="active" v-model="editFormActive" />
<InputLabel for="active" class="text-sm font-normal cursor-pointer"
>Active</InputLabel
>
</div>
</div>
</UpdateDialog>
</div>
<DialogFooter>
<Button variant="outline" @click="closeEdit">Cancel</Button>
<Button
@click="submitEdit"
:disabled="editForm.processing || !editForm.segment_id"
>Save</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout>
<ConfirmationModal :show="showDelete" @close="cancelDelete">
<template #title>
Delete configuration
</template>
<template #content>
Are you sure you want to delete configuration for type "{{ toDelete?.type?.name }}"?
</template>
<template #footer>
<button @click="cancelDelete" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2">Cancel</button>
<PrimaryButton @click="destroyConfig">Delete</PrimaryButton>
</template>
</ConfirmationModal>
<AlertDialog v-model:open="showDelete">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete configuration</AlertDialogTitle>
</AlertDialogHeader>
<div class="text-sm text-muted-foreground">
Are you sure you want to delete configuration for type "{{
toDelete?.type?.name
}}"?
</div>
<AlertDialogFooter>
<Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="destroyConfig">Delete</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@ -1,14 +1,28 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, useForm } from "@inertiajs/vue3";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import PrimaryButton from "@/Components/PrimaryButton.vue";
import { useForm } from "@inertiajs/vue3";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Button } from "@/Components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/Components/ui/dialog";
import InputLabel from "@/Components/InputLabel.vue";
import InputError from "@/Components/InputError.vue";
import { ref, onMounted, watch } from "vue";
import Multiselect from "vue-multiselect";
import { de } from "date-fns/locale";
import { ref, onMounted, watch, computed } from "vue";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { MoreHorizontal, Pencil, Settings } from "lucide-vue-next";
const props = defineProps({
settings: Array,
@ -25,12 +39,18 @@ const decisionOptions = ref([]);
const actionOptions = ref([]);
onMounted(() => {
segmentOptions.value = (props.segments || []).map((s) => ({ id: s.id, name: s.name }));
decisionOptions.value = (props.decisions || []).map((d) => ({
id: d.id,
name: d.name,
segmentOptions.value = (props.segments || []).map((s) => ({
label: s.name,
value: s.id,
}));
decisionOptions.value = (props.decisions || []).map((d) => ({
label: d.name,
value: d.id,
}));
actionOptions.value = (props.actions || []).map((a) => ({
label: a.name,
value: a.id,
}));
actionOptions.value = (props.actions || []).map((a) => ({ id: a.id, name: a.name }));
});
const form = useForm({
@ -117,18 +137,10 @@ const update = () => {
watch(
() => editForm.action_id,
(newActionId) => {
// Clear decision fields when action changes
/*editForm.initial_decision_id = null;
editForm.assign_decision_id = null;
editForm.complete_decision_id = null;
editForm.cancel_decision_id = null;*/
if (newActionId !== null) {
// Optionally, you can filter decisionOptions based on the selected action here
decisionOptions.value = (props.decisions || [])
.filter((decision) => decision.actions?.some((a) => a.id === newActionId) ?? true)
.map((d) => ({ id: d.id, name: d.name }));
// For simplicity, we are not implementing that logic now
console.log(decisionOptions.value);
.map((d) => ({ label: d.name, value: d.id }));
}
}
);
@ -136,20 +148,32 @@ watch(
watch(
() => form.action_id,
(newActionId) => {
// Clear decision fields when action changes
form.initial_decision_id = null;
form.assign_decision_id = null;
form.complete_decision_id = null;
if (newActionId !== null) {
// Optionally, you can filter decisionOptions based on the selected action here
decisionOptions.value = (props.decisions || [])
.filter((decision) => decision.actions?.some((a) => a.id === newActionId) ?? true)
.map((d) => ({ id: d.id, name: d.name }));
// For simplicity, we are not implementing that logic now
console.log(decisionOptions.value);
.map((d) => ({ label: d.name, value: d.id }));
}
}
);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "ID", sortable: true, class: "w-16" },
{ key: "segment", label: "Segment", sortable: false },
{ key: "action", label: "Action", sortable: false },
{ key: "initial_decision", label: "Initial Decision", sortable: false },
{ key: "assign_decision", label: "Assign Decision", sortable: false },
{ key: "complete_decision", label: "Complete Decision", sortable: false },
{ key: "cancel_decision", label: "Cancel Decision", sortable: false },
{ key: "return_segment", label: "Return Segment", sortable: false },
{ key: "queue_segment", label: "Queue Segment", sortable: false },
];
</script>
<template>
@ -157,383 +181,299 @@ watch(
<template #header></template>
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Field Job Settings</h2>
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
</div>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Settings :size="18" />
<CardTitle class="uppercase">Field Job Settings</CardTitle>
</div>
<Button @click="openCreate">+ New</Button>
</div>
</template>
<CreateDialog
:show="showCreate"
title="Create Field Job Setting"
confirm-text="Create"
:processing="form.processing"
@close="closeCreate"
@confirm="store"
>
<form @submit.prevent="store">
<div class="grid grid-cols-1 gap-4">
<div>
<InputLabel for="segment" value="Segment" />
<multiselect
id="segment"
v-model="form.segment_id"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select segment"
:append-to-body="true"
:custom-label="
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="action" value="Action" />
<multiselect
id="action"
v-model="form.action_id"
:options="actionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select action"
:append-to-body="true"
:custom-label="
(opt) => actionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.action_id" class="mt-1" />
</div>
<div>
<InputLabel for="initialDecision" value="Initial Decision" />
<multiselect
id="initialDecision"
v-model="form.initial_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select initial decision"
:append-to-body="true"
:disabled="!form.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="assignDecision" value="Assign Decision" />
<multiselect
id="assignDecision"
v-model="form.assign_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select assign decision"
:append-to-body="true"
:disabled="!form.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="completeDecision" value="Complete Decision" />
<multiselect
id="completeDecision"
v-model="form.complete_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select complete decision"
:append-to-body="true"
:disabled="!form.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="form.errors.complete_decision_id"
class="mt-1"
/>
</div>
<div class="mt-2">
<InputLabel for="cancelDecision" value="Cancel Decision" />
<multiselect
id="cancelDecision"
v-model="form.cancel_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select cancel decision (optional)"
:append-to-body="true"
:disabled="!form.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="returnSegment" value="Return Segment" />
<multiselect
id="returnSegment"
v-model="form.return_segment_id"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select return segment (optional)"
:append-to-body="true"
:custom-label="
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.return_segment_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="queueSegment" value="Queue Segment" />
<multiselect
id="queueSegment"
v-model="form.queue_segment_id"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select queue segment (optional)"
:append-to-body="true"
:custom-label="
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
</div>
<Dialog v-model:open="showCreate">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Field Job Setting</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="segment" value="Segment" />
<AppCombobox
id="segment"
v-model="form.segment_id"
:items="segmentOptions"
placeholder="Select segment"
button-class="w-full"
/>
<InputError :message="form.errors.segment_id" class="mt-1" />
</div>
</form>
</CreateDialog>
<UpdateDialog
:show="showEdit"
title="Edit Field Job Setting"
confirm-text="Save"
:processing="editForm.processing"
@close="closeEdit"
@confirm="update"
>
<form @submit.prevent="update">
<div class="grid grid-cols-1 gap-4">
<div>
<InputLabel for="edit-segment" value="Segment" />
<multiselect
id="edit-segment"
v-model="editForm.segment_id"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select segment"
:append-to-body="true"
:custom-label="
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="editForm.errors.segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-action" value="Action" />
<multiselect
id="edit-action"
v-model="editForm.action_id"
:options="actionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select action"
:append-to-body="true"
:custom-label="
(opt) => actionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError :message="editForm.errors.action_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-initialDecision" value="Initial Decision" />
<multiselect
id="edit-initialDecision"
v-model="editForm.initial_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select initial decision"
:append-to-body="true"
:disabled="!editForm.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="editForm.errors.initial_decision_id"
class="mt-1"
/>
</div>
<div>
<InputLabel for="edit-assignDecision" value="Assign Decision" />
<multiselect
id="edit-assignDecision"
v-model="editForm.assign_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select assign decision"
:append-to-body="true"
:disabled="!editForm.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="editForm.errors.assign_decision_id"
class="mt-1"
/>
</div>
<div class="mt-2">
<InputLabel for="edit-completeDecision" value="Complete Decision" />
<multiselect
id="edit-completeDecision"
v-model="editForm.complete_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select complete decision"
:disabled="!editForm.action_id"
:append-to-body="true"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="editForm.errors.complete_decision_id"
class="mt-1"
/>
</div>
<div class="mt-2">
<InputLabel for="edit-cancelDecision" value="Cancel Decision" />
<multiselect
id="edit-cancelDecision"
v-model="editForm.cancel_decision_id"
:options="decisionOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select cancel decision (optional)"
:append-to-body="true"
:disabled="!editForm.action_id"
:custom-label="
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="editForm.errors.cancel_decision_id"
class="mt-1"
/>
</div>
<div class="mt-2">
<InputLabel for="edit-returnSegment" value="Return Segment" />
<multiselect
id="edit-returnSegment"
v-model="editForm.return_segment_id"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select return segment (optional)"
:append-to-body="true"
:custom-label="
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="editForm.errors.return_segment_id"
class="mt-1"
/>
</div>
<div class="mt-2">
<InputLabel for="edit-queueSegment" value="Queue Segment" />
<multiselect
id="edit-queueSegment"
v-model="editForm.queue_segment_id"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Select queue segment (optional)"
:append-to-body="true"
:custom-label="
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
"
/>
<InputError
:message="editForm.errors.queue_segment_id"
class="mt-1"
/>
</div>
<div>
<InputLabel for="action" value="Action" />
<AppCombobox
id="action"
v-model="form.action_id"
:items="actionOptions"
placeholder="Select action"
button-class="w-full"
/>
<InputError :message="form.errors.action_id" class="mt-1" />
</div>
</form>
</UpdateDialog>
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">ID</th>
<th class="py-2 pr-4">Segment</th>
<th class="py-2 pr-4">Action</th>
<th class="py-2 pr-4">Initial Decision</th>
<th class="py-2 pr-4">Assign Decision</th>
<th class="py-2 pr-4">Complete Decision</th>
<th class="py-2 pr-4">Cancel Decision</th>
<th class="py-2 pr-4">Return Segment</th>
<th class="py-2 pr-4">Queue Segment</th>
<th class="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in settings" :key="row.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ row.id }}</td>
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
<td class="py-2 pr-4">{{ row.action?.name }}</td>
<td class="py-2 pr-4">
{{ row.initial_decision?.name || row.initialDecision?.name }}
</td>
<td class="py-2 pr-4">
{{ row.assign_decision?.name || row.assignDecision?.name }}
</td>
<td class="py-2 pr-4">
{{ row.complete_decision?.name || row.completeDecision?.name }}
</td>
<td class="py-2 pr-4">
{{ row.cancel_decision?.name || row.cancelDecision?.name }}
</td>
<td class="py-2 pr-4">
{{ row.return_segment?.name || row.returnSegment?.name }}
</td>
<td class="py-2 pr-4">
{{ row.queue_segment?.name || row.queueSegment?.name }}
</td>
<td class="py-2 pr-4">
<button
@click="openEdit(row)"
class="px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700"
>
Edit
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<InputLabel for="initialDecision" value="Initial Decision" />
<AppCombobox
id="initialDecision"
v-model="form.initial_decision_id"
:items="decisionOptions"
placeholder="Select initial decision"
:disabled="!form.action_id"
button-class="w-full"
/>
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="assignDecision" value="Assign Decision" />
<AppCombobox
id="assignDecision"
v-model="form.assign_decision_id"
:items="decisionOptions"
placeholder="Select assign decision"
:disabled="!form.action_id"
button-class="w-full"
/>
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="completeDecision" value="Complete Decision" />
<AppCombobox
id="completeDecision"
v-model="form.complete_decision_id"
:items="decisionOptions"
placeholder="Select complete decision"
:disabled="!form.action_id"
button-class="w-full"
/>
<InputError :message="form.errors.complete_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="cancelDecision" value="Cancel Decision" />
<AppCombobox
id="cancelDecision"
v-model="form.cancel_decision_id"
:items="decisionOptions"
placeholder="Select cancel decision (optional)"
:disabled="!form.action_id"
button-class="w-full"
/>
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="returnSegment" value="Return Segment" />
<AppCombobox
id="returnSegment"
v-model="form.return_segment_id"
:items="segmentOptions"
placeholder="Select return segment (optional)"
button-class="w-full"
/>
<InputError :message="form.errors.return_segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="queueSegment" value="Queue Segment" />
<AppCombobox
id="queueSegment"
v-model="form.queue_segment_id"
:items="segmentOptions"
placeholder="Select queue segment (optional)"
button-class="w-full"
/>
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeCreate">Cancel</Button>
<Button @click="store" :disabled="form.processing">Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showEdit">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Field Job Setting</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="edit-segment" value="Segment" />
<AppCombobox
id="edit-segment"
v-model="editForm.segment_id"
:items="segmentOptions"
placeholder="Select segment"
button-class="w-full"
/>
<InputError :message="editForm.errors.segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-action" value="Action" />
<AppCombobox
id="edit-action"
v-model="editForm.action_id"
:items="actionOptions"
placeholder="Select action"
button-class="w-full"
/>
<InputError :message="editForm.errors.action_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-initialDecision" value="Initial Decision" />
<AppCombobox
id="edit-initialDecision"
v-model="editForm.initial_decision_id"
:items="decisionOptions"
placeholder="Select initial decision"
:disabled="!editForm.action_id"
button-class="w-full"
/>
<InputError
:message="editForm.errors.initial_decision_id"
class="mt-1"
/>
</div>
<div>
<InputLabel for="edit-assignDecision" value="Assign Decision" />
<AppCombobox
id="edit-assignDecision"
v-model="editForm.assign_decision_id"
:items="decisionOptions"
placeholder="Select assign decision"
:disabled="!editForm.action_id"
button-class="w-full"
/>
<InputError
:message="editForm.errors.assign_decision_id"
class="mt-1"
/>
</div>
<div>
<InputLabel for="edit-completeDecision" value="Complete Decision" />
<AppCombobox
id="edit-completeDecision"
v-model="editForm.complete_decision_id"
:items="decisionOptions"
placeholder="Select complete decision"
:disabled="!editForm.action_id"
button-class="w-full"
/>
<InputError
:message="editForm.errors.complete_decision_id"
class="mt-1"
/>
</div>
<div>
<InputLabel for="edit-cancelDecision" value="Cancel Decision" />
<AppCombobox
id="edit-cancelDecision"
v-model="editForm.cancel_decision_id"
:items="decisionOptions"
placeholder="Select cancel decision (optional)"
:disabled="!editForm.action_id"
button-class="w-full"
/>
<InputError
:message="editForm.errors.cancel_decision_id"
class="mt-1"
/>
</div>
<div>
<InputLabel for="edit-returnSegment" value="Return Segment" />
<AppCombobox
id="edit-returnSegment"
v-model="editForm.return_segment_id"
:items="segmentOptions"
placeholder="Select return segment (optional)"
button-class="w-full"
/>
<InputError :message="editForm.errors.return_segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-queueSegment" value="Queue Segment" />
<AppCombobox
id="edit-queueSegment"
v-model="editForm.queue_segment_id"
:items="segmentOptions"
placeholder="Select queue segment (optional)"
button-class="w-full"
/>
<InputError :message="editForm.errors.queue_segment_id" class="mt-1" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeEdit">Cancel</Button>
<Button @click="update" :disabled="editForm.processing">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DataTableClient
:columns="columns"
:rows="settings"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-segment="{ row }">
{{ row.segment?.name }}
</template>
<template #cell-action="{ row }">
{{ row.action?.name }}
</template>
<template #cell-initial_decision="{ row }">
{{ row.initial_decision?.name || row.initialDecision?.name }}
</template>
<template #cell-assign_decision="{ row }">
{{ row.assign_decision?.name || row.assignDecision?.name }}
</template>
<template #cell-complete_decision="{ row }">
{{ row.complete_decision?.name || row.completeDecision?.name }}
</template>
<template #cell-cancel_decision="{ row }">
{{ row.cancel_decision?.name || row.cancelDecision?.name }}
</template>
<template #cell-return_segment="{ row }">
{{ row.return_segment?.name || row.returnSegment?.name }}
</template>
<template #cell-queue_segment="{ row }">
{{ row.queue_segment?.name || row.queueSegment?.name }}
</template>
<template #actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openEdit(row)">
<Pencil class="w-4 h-4 mr-2" />
Uredi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableClient>
</AppCard>
</div>
</div>
</AppLayout>

View File

@ -1,84 +1,105 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Link } from "@inertiajs/vue3";
import {
Layers,
CreditCard,
GitBranch,
Briefcase,
FileText,
Archive,
ArrowRight,
BarChart3,
} from "lucide-vue-next";
const settingsCards = [
{
title: "Segments",
description: "Manage segments used across the app.",
route: "settings.segments",
icon: Layers,
},
{
title: "Payments",
description: "Defaults for payments and auto-activity.",
route: "settings.payment.edit",
icon: CreditCard,
},
{
title: "Workflow",
description: "Configure actions and decisions relationships.",
route: "settings.workflow",
icon: GitBranch,
},
{
title: "Field Job Settings",
description: "Configure segment-based field job rules.",
route: "settings.fieldjob.index",
icon: Briefcase,
},
{
title: "Contract Configs",
description: "Auto-assign initial segments for contracts by type.",
route: "settings.contractConfigs.index",
icon: FileText,
},
{
title: "Archive Settings",
description: "Define rules for archiving or soft-deleting aged data.",
route: "settings.archive.index",
icon: Archive,
},
{
title: "Reports",
description: "Configure database-driven reports with dynamic queries.",
route: "settings.reports.index",
icon: BarChart3,
},
];
</script>
<template>
<AppLayout title="Settings">
<template #header></template>
<template #header />
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Segments</h3>
<p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p>
<Link
:href="route('settings.segments')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Segments</Link
>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Payments</h3>
<p class="text-sm text-gray-600 mb-4">
Defaults for payments and auto-activity.
</p>
<Link
:href="route('settings.payment.edit')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Payment Settings</Link
>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Workflow</h3>
<p class="text-sm text-gray-600 mb-4">
Configure actions and decisions relationships.
</p>
<Link
:href="route('settings.workflow')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Workflow</Link
>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Field Job Settings</h3>
<p class="text-sm text-gray-600 mb-4">
Configure segment-based field job rules.
</p>
<Link
:href="route('settings.fieldjob.index')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Field Job</Link
>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Contract Configs</h3>
<p class="text-sm text-gray-600 mb-4">
Auto-assign initial segments for contracts by type.
</p>
<Link
:href="route('settings.contractConfigs.index')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Contract Configs</Link
>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Archive Settings</h3>
<p class="text-sm text-gray-600 mb-4">
Define rules for archiving or soft-deleting aged data.
</p>
<Link
:href="route('settings.archive.index')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Archive Settings</Link
>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
<p class="mt-2 text-sm text-muted-foreground">
Manage your application configuration and preferences
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card
v-for="card in settingsCards"
:key="card.route"
class="hover:shadow-lg transition-shadow"
>
<CardHeader>
<div class="flex items-center gap-3 mb-2">
<div
class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary"
>
<component :is="card.icon" :size="20" />
</div>
<CardTitle class="text-lg">{{ card.title }}</CardTitle>
</div>
<CardDescription>{{ card.description }}</CardDescription>
</CardHeader>
<CardContent>
<Link :href="route(card.route)">
<Button class="w-full group">
Open Settings
<ArrowRight
class="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1"
/>
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
</div>

View File

@ -1,362 +0,0 @@
<script setup>
// flowbite-vue table imports removed; using DataTableClient
import { EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import { computed, onMounted, ref } from "vue";
import { router, useForm } from "@inertiajs/vue3";
import InputLabel from "@/Components/InputLabel.vue";
import TextInput from "@/Components/TextInput.vue";
import Multiselect from "vue-multiselect";
import PrimaryButton from "@/Components/PrimaryButton.vue";
import ActionMessage from "@/Components/ActionMessage.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
const props = defineProps({
actions: Array,
decisions: Array,
segments: Array,
});
const drawerEdit = ref(false);
const drawerCreate = ref(false);
const showDelete = ref(false);
const toDelete = ref(null);
const search = ref("");
const selectedSegment = ref(null);
const selectOptions = ref([]);
const segmentOptions = ref([]);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true, class: "w-16" },
{ key: "name", label: "Ime", sortable: true },
{ key: "color_tag", label: "Barva", sortable: false },
{ key: "segment", label: "Segment", sortable: false },
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
];
const form = useForm({
id: 0,
name: "",
color_tag: "",
segment_id: null,
decisions: [],
});
const createForm = useForm({
name: "",
color_tag: "",
segment_id: null,
decisions: [],
});
const openEditDrawer = (item) => {
form.decisions = [];
form.id = item.id;
form.name = item.name;
form.color_tag = item.color_tag;
form.segment_id = item.segment ? item.segment.id : null;
drawerEdit.value = true;
item.decisions.forEach((d) => {
form.decisions.push({
name: d.name,
id: d.id,
});
});
};
const closeEditDrawer = () => {
drawerEdit.value = false;
form.reset();
};
const openCreateDrawer = () => {
createForm.reset();
drawerCreate.value = true;
};
const closeCreateDrawer = () => {
drawerCreate.value = false;
createForm.reset();
};
// removed unused color picker change handler; InlineColorPicker handles updates
onMounted(() => {
props.decisions.forEach((d) => {
selectOptions.value.push({
name: d.name,
id: d.id,
});
});
props.segments.forEach((s) => {
segmentOptions.value.push({
name: s.name,
id: s.id,
});
});
});
const filtered = computed(() => {
const term = search.value?.toLowerCase() ?? "";
return (props.actions || []).filter((a) => {
const matchesSearch =
!term ||
a.name?.toLowerCase().includes(term) ||
a.color_tag?.toLowerCase().includes(term);
const matchesSegment =
!selectedSegment.value || a.segment?.id === selectedSegment.value;
return matchesSearch && matchesSegment;
});
});
const update = () => {
form.put(route("settings.actions.update", { id: form.id }), {
onSuccess: () => {
closeEditDrawer();
},
});
};
const store = () => {
createForm.post(route("settings.actions.store"), {
onSuccess: () => {
closeCreateDrawer();
},
});
};
const confirmDelete = (action) => {
toDelete.value = action;
showDelete.value = true;
};
const cancelDelete = () => {
toDelete.value = null;
showDelete.value = false;
};
const destroyAction = () => {
if (!toDelete.value) return;
router.delete(route("settings.actions.destroy", { id: toDelete.value.id }), {
preserveScroll: true,
onFinish: () => cancelDelete(),
});
};
</script>
<template>
<div class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex gap-3 items-center w-full sm:w-auto">
<TextInput v-model="search" placeholder="Iskanje..." class="w-full sm:w-64" />
<div class="w-64">
<Multiselect
v-model="selectedSegment"
:options="segmentOptions.map((o) => o.id)"
:multiple="false"
:searchable="true"
placeholder="Filter po segmentu"
:append-to-body="true"
:custom-label="(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''"
/>
</div>
</div>
<PrimaryButton @click="openCreateDrawer">+ Dodaj akcijo</PrimaryButton>
</div>
<div class="px-4 pb-4">
<DataTableClient
:columns="columns"
:rows="filtered"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:showToolbar="false"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-color_tag="{ row }">
<div class="flex items-center gap-2">
<span
v-if="row.color_tag"
class="inline-block h-4 w-4 rounded"
:style="{ backgroundColor: row.color_tag }"
></span>
<span>{{ row.color_tag || "" }}</span>
</div>
</template>
<template #cell-decisions="{ row }">
{{ row.decisions?.length ?? 0 }}
</template>
<template #cell-segment="{ row }">
<span>
{{ row.segment?.name || "" }}
</span>
</template>
<template #actions="{ row }">
<button class="px-2" @click="openEditDrawer(row)">
<EditIcon size="md" css="text-gray-500" />
</button>
<button
class="px-2 disabled:opacity-40"
:disabled="(row.activities_count ?? 0) > 0"
@click="confirmDelete(row)"
>
<TrashBinIcon size="md" css="text-red-500" />
</button>
</template>
</DataTableClient>
</div>
<UpdateDialog
:show="drawerEdit"
title="Spremeni akcijo"
confirm-text="Shrani"
:processing="form.processing"
@close="closeEditDrawer"
@confirm="update"
>
<form @submit.prevent="update">
<div class="col-span-6 sm:col-span-4">
<InputLabel for="name" value="Ime" />
<TextInput
id="name"
ref="nameInput"
v-model="form.name"
type="text"
class="mt-1 block w-full"
autocomplete="name"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="colorTag" value="Barva" />
<div class="mt-1">
<InlineColorPicker v-model="form.color_tag" />
</div>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="segmentEdit" value="Segment" />
<multiselect
id="segmentEdit"
v-model="form.segment_id"
:options="segmentOptions.map((s) => s.id)"
:multiple="false"
:searchable="true"
:taggable="false"
placeholder="Izberi segment"
:append-to-body="true"
:custom-label="(opt) => segmentOptions.find((s) => s.id === opt)?.name || ''"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="decisions" value="Odločitve" />
<multiselect
id="decisions"
ref="decisionsSelect"
v-model="form.decisions"
:options="selectOptions"
:multiple="true"
track-by="id"
:taggable="true"
placeholder="Dodaj odločitev"
:append-to-body="true"
label="name"
/>
</div>
<div v-if="form.recentlySuccessful" class="mt-4 text-sm text-green-600">
Shranjuje.
</div>
</form>
</UpdateDialog>
<CreateDialog
:show="drawerCreate"
title="Dodaj akcijo"
confirm-text="Dodaj"
:processing="createForm.processing"
@close="closeCreateDrawer"
@confirm="store"
>
<form @submit.prevent="store">
<div class="col-span-6 sm:col-span-4">
<InputLabel for="nameCreate" value="Ime" />
<TextInput
id="nameCreate"
v-model="createForm.name"
type="text"
class="mt-1 block w-full"
autocomplete="name"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="colorTagCreate" value="Barva" />
<div class="mt-1">
<InlineColorPicker v-model="createForm.color_tag" />
</div>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="segmentCreate" value="Segment" />
<multiselect
id="segmentCreate"
v-model="createForm.segment_id"
:options="segmentOptions.map((s) => s.id)"
:multiple="false"
:searchable="true"
:taggable="false"
placeholder="Izberi segment"
:append-to-body="true"
:custom-label="(opt) => segmentOptions.find((s) => s.id === opt)?.name || ''"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="decisionsCreate" value="Odločitve" />
<multiselect
id="decisionsCreate"
v-model="createForm.decisions"
:options="selectOptions"
:multiple="true"
track-by="id"
:taggable="true"
placeholder="Dodaj odločitev"
:append-to-body="true"
label="name"
/>
</div>
<div v-if="createForm.recentlySuccessful" class="mt-4 text-sm text-green-600">
Shranjuje.
</div>
</form>
</CreateDialog>
<ConfirmationModal :show="showDelete" @close="cancelDelete">
<template #title> Delete action </template>
<template #content>
Are you sure you want to delete action "{{ toDelete?.name }}"? This cannot be
undone.
</template>
<template #footer>
<button
@click="cancelDelete"
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2"
>
Cancel
</button>
<PrimaryButton @click="destroyAction">Delete</PrimaryButton>
</template>
</ConfirmationModal>
</template>

View File

@ -1,103 +1,168 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { useForm } from '@inertiajs/vue3'
import { computed, watch } from 'vue'
import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Wallet } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
setting: Object,
decisions: Array,
actions: Array,
})
});
const form = useForm({
default_currency: props.setting?.default_currency ?? 'EUR',
default_currency: props.setting?.default_currency ?? "EUR",
create_activity_on_payment: !!props.setting?.create_activity_on_payment,
default_action_id: props.setting?.default_action_id ?? null,
default_decision_id: props.setting?.default_decision_id ?? null,
activity_note_template: props.setting?.activity_note_template ?? 'Prejeto plačilo: {amount} {currency}',
})
activity_note_template:
props.setting?.activity_note_template ?? "Prejeto plačilo: {amount} {currency}",
});
const filteredDecisions = computed(() => {
const actionId = form.default_action_id
if (!actionId) return []
const action = props.actions?.find(a => a.id === actionId)
if (!action || !action.decision_ids) return []
const ids = new Set(action.decision_ids)
return (props.decisions || []).filter(d => ids.has(d.id))
})
const actionId = form.default_action_id;
if (!actionId) return [];
const action = props.actions?.find((a) => a.id === actionId);
if (!action || !action.decision_ids) return [];
const ids = new Set(action.decision_ids);
return (props.decisions || []).filter((d) => ids.has(d.id));
});
watch(() => form.default_action_id, (newVal) => {
if (!newVal) {
form.default_decision_id = null
} else {
// If current decision not in filtered list, clear it
const ids = new Set((filteredDecisions.value || []).map(d => d.id))
if (!ids.has(form.default_decision_id)) {
form.default_decision_id = null
watch(
() => form.default_action_id,
(newVal) => {
if (!newVal) {
form.default_decision_id = null;
} else {
// If current decision not in filtered list, clear it
const ids = new Set((filteredDecisions.value || []).map((d) => d.id));
if (!ids.has(form.default_decision_id)) {
form.default_decision_id = null;
}
}
}
})
);
const submit = () => {
form.put(route('settings.payment.update'), {
form.put(route("settings.payment.update"), {
preserveScroll: true,
})
}
});
};
</script>
<template>
<AppLayout title="Nastavitve plačil">
<template #header></template>
<div class="max-w-3xl mx-auto p-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h1 class="text-xl font-semibold text-gray-900">Nastavitve plačil</h1>
<div class="mt-6 grid gap-6">
<div>
<label class="block text-sm text-gray-700 mb-1">Privzeta valuta</label>
<input type="text" maxlength="3" v-model="form.default_currency" class="w-40 rounded border-gray-300" />
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">{{ form.errors.default_currency }}</div>
</div>
<div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="form.create_activity_on_payment" />
<span class="text-sm text-gray-700">Ustvari aktivnost ob dodanem plačilu</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm text-gray-700 mb-1">Privzeto dejanje</label>
<select v-model="form.default_action_id" class="w-full rounded border-gray-300">
<option :value="null"> Brez </option>
<option v-for="a in actions" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">{{ form.errors.default_action_id }}</div>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<Wallet :size="18" />
<CardTitle class="uppercase">Nastavitve plačil</CardTitle>
</div>
</template>
<div class="space-y-6 p-4 border-t">
<div>
<label class="block text-sm text-gray-700 mb-1">Privzeta odločitev</label>
<select v-model="form.default_decision_id" class="w-full rounded border-gray-300" :disabled="!form.default_action_id">
<option :value="null"> Najprej izberite dejanje </option>
<option v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<div v-if="form.errors.default_decision_id" class="text-sm text-red-600 mt-1">{{ form.errors.default_decision_id }}</div>
<InputLabel for="currency">Privzeta valuta</InputLabel>
<Input
id="currency"
v-model="form.default_currency"
maxlength="3"
class="w-40"
/>
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">
{{ form.errors.default_currency }}
</div>
</div>
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">Predloga opombe aktivnosti</label>
<input type="text" v-model="form.activity_note_template" class="w-full rounded border-gray-300" />
<p class="text-xs text-gray-500 mt-1">Podprti žetoni: {amount}, {currency}</p>
<div v-if="form.errors.activity_note_template" class="text-sm text-red-600 mt-1">{{ form.errors.activity_note_template }}</div>
</div>
<div class="flex items-center gap-2">
<Checkbox id="create-activity" v-model="form.create_activity_on_payment" />
<InputLabel for="create-activity" class="text-sm font-normal cursor-pointer">
Ustvari aktivnost ob dodanem plačilu
</InputLabel>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<InputLabel for="default-action">Privzeto dejanje</InputLabel>
<Select v-model="form.default_action_id">
<SelectTrigger id="default-action">
<SelectValue placeholder="— Brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Brez </SelectItem>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">{{
a.name
}}</SelectItem>
</SelectContent>
</Select>
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">
{{ form.errors.default_action_id }}
</div>
</div>
<div>
<InputLabel for="default-decision">Privzeta odločitev</InputLabel>
<Select
v-model="form.default_decision_id"
:disabled="!form.default_action_id"
>
<SelectTrigger id="default-decision">
<SelectValue placeholder="— Najprej izberite dejanje —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Najprej izberite dejanje </SelectItem>
<SelectItem v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{
d.name
}}</SelectItem>
</SelectContent>
</Select>
<div
v-if="form.errors.default_decision_id"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.default_decision_id }}
</div>
</div>
</div>
<div>
<InputLabel for="note-template">Predloga opombe aktivnosti</InputLabel>
<Input id="note-template" v-model="form.activity_note_template" />
<p class="text-xs text-gray-500 mt-1">Podprti žetoni: {amount}, {currency}</p>
<div
v-if="form.errors.activity_note_template"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.activity_note_template }}
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="form.reset()">Ponastavi</button>
<button type="button" class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="form.processing" @click="submit">Shrani</button>
<Button variant="outline" @click="form.reset()">Ponastavi</Button>
<Button @click="submit" :disabled="form.processing">Shrani</Button>
</div>
</div>
</div>
</AppCard>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,134 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import { Badge } from "@/Components/ui/badge";
import { Link } from "@inertiajs/vue3";
import { ArrowLeft, BarChart3, Database, Columns, Filter, Code, ArrowUpDown } from "lucide-vue-next";
import EntitiesSection from "./Partials/EntitiesSection.vue";
import ColumnsSection from "./Partials/ColumnsSection.vue";
import FiltersSection from "./Partials/FiltersSection.vue";
import ConditionsSection from "./Partials/ConditionsSection.vue";
import OrdersSection from "./Partials/OrdersSection.vue";
const props = defineProps({
report: Object,
});
</script>
<template>
<AppLayout :title="`Edit Report: ${report.name}`">
<template #header>
<div class="flex items-center gap-4">
<Link :href="route('settings.reports.index')">
<Button variant="ghost" size="icon">
<ArrowLeft class="h-5 w-5" />
</Button>
</Link>
<div>
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Edit Report: {{ report.name }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Configure entities, columns, filters, and conditions
</p>
</div>
</div>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Report Info Header -->
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<BarChart3 class="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>{{ report.name }}</CardTitle>
<CardDescription class="mt-1">
{{ report.description || "No description" }}
</CardDescription>
</div>
</div>
<div class="flex gap-2">
<Badge v-if="!report.enabled" variant="secondary">Disabled</Badge>
<Badge v-if="report.category" variant="outline">{{ report.category }}</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Slug:</span>
<span class="ml-2 font-mono text-xs">{{ report.slug }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Order:</span>
<span class="ml-2">{{ report.order }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Status:</span>
<span class="ml-2">{{ report.enabled ? "Enabled" : "Disabled" }}</span>
</div>
</div>
</CardContent>
</Card>
<!-- Configuration Tabs -->
<Tabs default-value="entities" class="space-y-6">
<TabsList class="grid w-full grid-cols-5">
<TabsTrigger value="entities" class="flex items-center gap-2">
<Database class="h-4 w-4" />
Entities
<Badge variant="secondary" class="ml-1">{{ report.entities?.length || 0 }}</Badge>
</TabsTrigger>
<TabsTrigger value="columns" class="flex items-center gap-2">
<Columns class="h-4 w-4" />
Columns
<Badge variant="secondary" class="ml-1">{{ report.columns?.length || 0 }}</Badge>
</TabsTrigger>
<TabsTrigger value="filters" class="flex items-center gap-2">
<Filter class="h-4 w-4" />
Filters
<Badge variant="secondary" class="ml-1">{{ report.filters?.length || 0 }}</Badge>
</TabsTrigger>
<TabsTrigger value="conditions" class="flex items-center gap-2">
<Code class="h-4 w-4" />
Conditions
<Badge variant="secondary" class="ml-1">{{ report.conditions?.length || 0 }}</Badge>
</TabsTrigger>
<TabsTrigger value="orders" class="flex items-center gap-2">
<ArrowUpDown class="h-4 w-4" />
Orders
<Badge variant="secondary" class="ml-1">{{ report.orders?.length || 0 }}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="entities">
<EntitiesSection :report="report" :entities="report.entities || []" />
</TabsContent>
<TabsContent value="columns">
<ColumnsSection :report="report" :columns="report.columns || []" />
</TabsContent>
<TabsContent value="filters">
<FiltersSection :report="report" :filters="report.filters || []" />
</TabsContent>
<TabsContent value="conditions">
<ConditionsSection :report="report" :conditions="report.conditions || []" />
</TabsContent>
<TabsContent value="orders">
<OrdersSection :report="report" :orders="report.orders || []" />
</TabsContent>
</Tabs>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,359 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/Components/ui/dropdown-menu";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { useForm, router, Link } from "@inertiajs/vue3";
import { ref } from "vue";
import { BarChart3, MoreHorizontal, Pencil, Trash, Power, PowerOff, Plus, Database } from "lucide-vue-next";
const props = defineProps({
reports: Array,
});
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const editingReport = ref(null);
const createForm = useForm({
slug: "",
name: "",
description: "",
category: "",
enabled: true,
order: 0,
});
const editForm = useForm({
slug: "",
name: "",
description: "",
category: "",
enabled: true,
order: 0,
});
function openCreateDialog() {
createForm.reset();
showCreateDialog.value = true;
}
function submitCreate() {
createForm.post(route("settings.reports.store"), {
preserveScroll: true,
onSuccess: () => {
showCreateDialog.value = false;
createForm.reset();
},
});
}
function openEditDialog(report) {
editingReport.value = report;
editForm.slug = report.slug;
editForm.name = report.name;
editForm.description = report.description || "";
editForm.category = report.category || "";
editForm.enabled = report.enabled;
editForm.order = report.order;
showEditDialog.value = true;
}
function submitEdit() {
editForm.put(route("settings.reports.update", editingReport.value.id), {
preserveScroll: true,
onSuccess: () => {
showEditDialog.value = false;
editForm.reset();
editingReport.value = null;
},
});
}
function toggleEnabled(report) {
router.post(
route("settings.reports.toggle", report.id),
{},
{
preserveScroll: true,
}
);
}
function deleteReport(report) {
if (confirm(`Are you sure you want to delete "${report.name}"?`)) {
router.delete(route("settings.reports.destroy", report.id), {
preserveScroll: true,
});
}
}
</script>
<template>
<AppLayout title="Reports Settings">
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Reports Settings
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<AppCard>
<template #icon>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<BarChart3 class="h-6 w-6 text-primary" />
</div>
</template>
<template #header>
<CardTitle>Database Reports</CardTitle>
<CardDescription>
Manage configurable reports with dynamic queries and filters
</CardDescription>
</template>
<template #headerActions>
<Button @click="openCreateDialog">
<Plus class="mr-2 h-4 w-4" />
Create Report
</Button>
</template>
<div class="space-y-4">
<div v-if="reports.length === 0" class="text-center py-8 text-gray-500">
No reports configured yet. Create your first report to get started.
</div>
<Card v-for="report in reports" :key="report.id" class="overflow-hidden">
<CardHeader class="bg-gray-50 dark:bg-gray-800/50">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<CardTitle class="text-base">{{ report.name }}</CardTitle>
<Badge v-if="!report.enabled" variant="secondary">Disabled</Badge>
<Badge v-if="report.category" variant="outline">{{ report.category }}</Badge>
</div>
<CardDescription class="mt-1">
{{ report.description || "No description" }}
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openEditDialog(report)">
<Pencil class="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem as-child>
<Link :href="route('settings.reports.edit', report.id)" class="flex items-center cursor-pointer">
<Database class="mr-2 h-4 w-4" />
Configure Details
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="toggleEnabled(report)">
<Power v-if="report.enabled" class="mr-2 h-4 w-4" />
<PowerOff v-else class="mr-2 h-4 w-4" />
{{ report.enabled ? "Disable" : "Enable" }}
</DropdownMenuItem>
<DropdownMenuItem @click="deleteReport(report)" class="text-destructive">
<Trash class="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent class="pt-4">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Slug:</span>
<span class="ml-2 font-mono text-xs">{{ report.slug }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Order:</span>
<span class="ml-2">{{ report.order }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</AppCard>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Report</DialogTitle>
<DialogDescription>
Create a new database-driven report configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="space-y-2">
<Label for="create-slug">Slug *</Label>
<Input
id="create-slug"
v-model="createForm.slug"
placeholder="active-contracts"
required
/>
<p class="text-xs text-gray-500">Unique identifier for the report</p>
</div>
<div class="space-y-2">
<Label for="create-name">Name *</Label>
<Input
id="create-name"
v-model="createForm.name"
placeholder="Active Contracts"
required
/>
</div>
<div class="space-y-2">
<Label for="create-description">Description</Label>
<Textarea
id="create-description"
v-model="createForm.description"
placeholder="Report description..."
rows="3"
/>
</div>
<div class="space-y-2">
<Label for="create-category">Category</Label>
<Input
id="create-category"
v-model="createForm.category"
placeholder="contracts, activities, financial..."
/>
</div>
<div class="space-y-2">
<Label for="create-order">Display Order</Label>
<Input
id="create-order"
v-model.number="createForm.order"
type="number"
placeholder="0"
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="create-enabled"
v-model="createForm.enabled"
/>
<Label for="create-enabled" class="cursor-pointer">
Enabled
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="createForm.processing">
Create Report
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Edit Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Report</DialogTitle>
<DialogDescription>
Update report configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitEdit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-slug">Slug *</Label>
<Input
id="edit-slug"
v-model="editForm.slug"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-name">Name *</Label>
<Input
id="edit-name"
v-model="editForm.name"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-description">Description</Label>
<Textarea
id="edit-description"
v-model="editForm.description"
rows="3"
/>
</div>
<div class="space-y-2">
<Label for="edit-category">Category</Label>
<Input
id="edit-category"
v-model="editForm.category"
/>
</div>
<div class="space-y-2">
<Label for="edit-order">Display Order</Label>
<Input
id="edit-order"
v-model.number="editForm.order"
type="number"
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="edit-enabled"
v-model="editForm.enabled"
/>
<Label for="edit-enabled" class="cursor-pointer">
Enabled
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showEditDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="editForm.processing">
Update Report
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,354 @@
<script setup>
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { Textarea } from "@/Components/ui/textarea";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Plus, Pencil, Trash, Eye, EyeOff } from "lucide-vue-next";
const props = defineProps({
report: Object,
columns: Array,
});
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const editingColumn = ref(null);
const createForm = useForm({
key: "",
label: "",
type: "string",
expression: "",
sortable: true,
visible: true,
order: 0,
format_options: null,
});
const editForm = useForm({
key: "",
label: "",
type: "string",
expression: "",
sortable: true,
visible: true,
order: 0,
format_options: null,
});
function openCreateDialog() {
createForm.reset();
createForm.order = props.columns.length;
showCreateDialog.value = true;
}
function submitCreate() {
createForm.post(route("settings.reports.columns.store", props.report.id), {
preserveScroll: true,
onSuccess: () => {
showCreateDialog.value = false;
createForm.reset();
},
});
}
function openEditDialog(column) {
editingColumn.value = column;
editForm.key = column.key;
editForm.label = column.label;
editForm.type = column.type;
editForm.expression = column.expression;
editForm.sortable = column.sortable;
editForm.visible = column.visible;
editForm.order = column.order;
editForm.format_options = column.format_options;
showEditDialog.value = true;
}
function submitEdit() {
editForm.put(route("settings.reports.columns.update", editingColumn.value.id), {
preserveScroll: true,
onSuccess: () => {
showEditDialog.value = false;
editForm.reset();
editingColumn.value = null;
},
});
}
function deleteColumn(column) {
if (confirm("Are you sure you want to delete this column?")) {
router.delete(route("settings.reports.columns.destroy", column.id), {
preserveScroll: true,
});
}
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>Report Columns</CardTitle>
<CardDescription>
Define which columns to select and display in the report
</CardDescription>
</div>
<Button @click="openCreateDialog">
<Plus class="mr-2 h-4 w-4" />
Add Column
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="columns.length === 0" class="text-center py-8 text-gray-500">
No columns configured. Add columns to display in the report.
</div>
<div v-else class="space-y-3">
<div
v-for="column in columns"
:key="column.id"
class="flex items-start justify-between rounded-lg border p-4"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Eye v-if="column.visible" class="h-4 w-4 text-green-600" />
<EyeOff v-else class="h-4 w-4 text-gray-400" />
<span class="font-semibold">{{ column.label }}</span>
<Badge variant="outline">{{ column.type }}</Badge>
<Badge v-if="column.sortable" variant="secondary">sortable</Badge>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono mb-1">
{{ column.key }}
</div>
<div class="text-sm text-gray-500 font-mono">
{{ column.expression }}
</div>
<div class="text-xs text-gray-500 mt-1">Order: {{ column.order }}</div>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="icon" @click="openEditDialog(column)">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="deleteColumn(column)">
<Trash class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Column</DialogTitle>
<DialogDescription>
Add a new column to the report output
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="create-key">Key *</Label>
<Input
id="create-key"
v-model="createForm.key"
placeholder="contract_reference"
required
/>
</div>
<div class="space-y-2">
<Label for="create-label">Label *</Label>
<Input
id="create-label"
v-model="createForm.label"
placeholder="Contract Reference"
required
/>
</div>
</div>
<div class="space-y-2">
<Label for="create-type">Type *</Label>
<Select v-model="createForm.type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
<SelectItem value="currency">currency</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="create-expression">SQL Expression *</Label>
<Textarea
id="create-expression"
v-model="createForm.expression"
placeholder="contracts.reference"
rows="2"
required
/>
<p class="text-xs text-gray-500">SQL expression or column path</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center space-x-2">
<Checkbox
id="create-sortable"
v-model="createForm.sortable"
/>
<Label for="create-sortable" class="cursor-pointer">
Sortable
</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="create-visible"
v-model="createForm.visible"
/>
<Label for="create-visible" class="cursor-pointer">
Visible
</Label>
</div>
</div>
<div class="space-y-2">
<Label for="create-order">Order</Label>
<Input
id="create-order"
v-model.number="createForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="createForm.processing">
Add Column
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Edit Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Column</DialogTitle>
<DialogDescription>
Update column configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitEdit" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-key">Key *</Label>
<Input
id="edit-key"
v-model="editForm.key"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-label">Label *</Label>
<Input
id="edit-label"
v-model="editForm.label"
required
/>
</div>
</div>
<div class="space-y-2">
<Label for="edit-type">Type *</Label>
<Select v-model="editForm.type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
<SelectItem value="currency">currency</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="edit-expression">SQL Expression *</Label>
<Textarea
id="edit-expression"
v-model="editForm.expression"
rows="2"
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center space-x-2">
<Checkbox
id="edit-sortable"
v-model="editForm.sortable"
/>
<Label for="edit-sortable" class="cursor-pointer">
Sortable
</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="edit-visible"
v-model="editForm.visible"
/>
<Label for="edit-visible" class="cursor-pointer">
Visible
</Label>
</div>
</div>
<div class="space-y-2">
<Label for="edit-order">Order</Label>
<Input
id="edit-order"
v-model.number="editForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showEditDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="editForm.processing">
Update Column
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,418 @@
<script setup>
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { Textarea } from "@/Components/ui/textarea";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Plus, Pencil, Trash } from "lucide-vue-next";
const props = defineProps({
report: Object,
conditions: Array,
});
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const editingCondition = ref(null);
const createForm = useForm({
column: "",
operator: "=",
value_type: "static",
value: "",
filter_key: "",
logical_operator: "AND",
group_id: 1,
order: 0,
enabled: true,
});
const editForm = useForm({
column: "",
operator: "=",
value_type: "static",
value: "",
filter_key: "",
logical_operator: "AND",
group_id: 1,
order: 0,
enabled: true,
});
function openCreateDialog() {
createForm.reset();
createForm.order = props.conditions.length;
showCreateDialog.value = true;
}
function submitCreate() {
createForm.post(route("settings.reports.conditions.store", props.report.id), {
preserveScroll: true,
onSuccess: () => {
showCreateDialog.value = false;
createForm.reset();
},
});
}
function openEditDialog(condition) {
editingCondition.value = condition;
editForm.column = condition.column;
editForm.operator = condition.operator;
editForm.value_type = condition.value_type;
editForm.value = condition.value || "";
editForm.filter_key = condition.filter_key || "";
editForm.logical_operator = condition.logical_operator;
editForm.group_id = condition.group_id;
editForm.order = condition.order;
editForm.enabled = condition.enabled;
showEditDialog.value = true;
}
function submitEdit() {
editForm.put(route("settings.reports.conditions.update", editingCondition.value.id), {
preserveScroll: true,
onSuccess: () => {
showEditDialog.value = false;
editForm.reset();
editingCondition.value = null;
},
});
}
function deleteCondition(condition) {
if (confirm("Are you sure you want to delete this condition?")) {
router.delete(route("settings.reports.conditions.destroy", condition.id), {
preserveScroll: true,
});
}
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>WHERE Conditions</CardTitle>
<CardDescription>
Define WHERE clause rules for filtering data
</CardDescription>
</div>
<Button @click="openCreateDialog">
<Plus class="mr-2 h-4 w-4" />
Add Condition
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="conditions.length === 0" class="text-center py-8 text-gray-500">
No conditions configured. Add WHERE conditions to filter query results.
</div>
<div v-else class="space-y-3">
<div
v-for="condition in conditions"
:key="condition.id"
class="flex items-start justify-between rounded-lg border p-4"
:class="{ 'opacity-50': !condition.enabled }"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Badge :variant="condition.logical_operator === 'AND' ? 'default' : 'secondary'">
{{ condition.logical_operator }}
</Badge>
<Badge variant="outline">Group {{ condition.group_id || 0 }}</Badge>
<Badge v-if="!condition.enabled" variant="secondary">disabled</Badge>
</div>
<div class="text-sm font-mono mb-1">
{{ condition.column }} {{ condition.operator }}
<span v-if="condition.value_type === 'static'" class="text-blue-600">"{{ condition.value }}"</span>
<span v-else-if="condition.value_type === 'filter'" class="text-green-600">filter({{ condition.filter_key }})</span>
<span v-else class="text-purple-600">{{ condition.value }}</span>
</div>
<div class="text-xs text-gray-500">
Type: {{ condition.value_type }} | Order: {{ condition.order }}
</div>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="icon" @click="openEditDialog(condition)">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="deleteCondition(condition)">
<Trash class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Condition</DialogTitle>
<DialogDescription>
Add a new WHERE clause condition
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="space-y-2">
<Label for="create-column">Column *</Label>
<Input
id="create-column"
v-model="createForm.column"
placeholder="contracts.start_date"
required
/>
</div>
<div class="space-y-2">
<Label for="create-operator">Operator *</Label>
<Select v-model="createForm.operator">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">></SelectItem>
<SelectItem value="<"><</SelectItem>
<SelectItem value=">=">>=</SelectItem>
<SelectItem value="<="><=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">NOT IN</SelectItem>
<SelectItem value="BETWEEN">BETWEEN</SelectItem>
<SelectItem value="IS NULL">IS NULL</SelectItem>
<SelectItem value="IS NOT NULL">IS NOT NULL</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="create-value-type">Value Type *</Label>
<Select v-model="createForm.value_type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static (hardcoded value)</SelectItem>
<SelectItem value="filter">filter (from user input)</SelectItem>
<SelectItem value="expression">expression (SQL expression)</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="createForm.value_type === 'filter'" class="space-y-2">
<Label for="create-filter-key">Filter Key *</Label>
<Input
id="create-filter-key"
v-model="createForm.filter_key"
placeholder="client_uuid"
/>
</div>
<div v-else class="space-y-2">
<Label for="create-value">Value</Label>
<Textarea
id="create-value"
v-model="createForm.value"
placeholder="Value or expression..."
rows="2"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="create-logical">Logical Operator *</Label>
<Select v-model="createForm.logical_operator">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="create-group">Group ID</Label>
<Input
id="create-group"
v-model.number="createForm.group_id"
type="number"
placeholder="1"
/>
</div>
</div>
<div class="space-y-2">
<Label for="create-order">Order</Label>
<Input
id="create-order"
v-model.number="createForm.order"
type="number"
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="create-enabled"
v-model="createForm.enabled"
/>
<Label for="create-enabled" class="cursor-pointer">
Enabled
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="createForm.processing">
Add Condition
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Edit Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Condition</DialogTitle>
<DialogDescription>
Update condition configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitEdit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-column">Column *</Label>
<Input
id="edit-column"
v-model="editForm.column"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-operator">Operator *</Label>
<Select v-model="editForm.operator">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">></SelectItem>
<SelectItem value="<"><</SelectItem>
<SelectItem value=">=">>=</SelectItem>
<SelectItem value="<="><=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">NOT IN</SelectItem>
<SelectItem value="BETWEEN">BETWEEN</SelectItem>
<SelectItem value="IS NULL">IS NULL</SelectItem>
<SelectItem value="IS NOT NULL">IS NOT NULL</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="edit-value-type">Value Type *</Label>
<Select v-model="editForm.value_type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static (hardcoded value)</SelectItem>
<SelectItem value="filter">filter (from user input)</SelectItem>
<SelectItem value="expression">expression (SQL expression)</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="editForm.value_type === 'filter'" class="space-y-2">
<Label for="edit-filter-key">Filter Key *</Label>
<Input
id="edit-filter-key"
v-model="editForm.filter_key"
/>
</div>
<div v-else class="space-y-2">
<Label for="edit-value">Value</Label>
<Textarea
id="edit-value"
v-model="editForm.value"
rows="2"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-logical">Logical Operator *</Label>
<Select v-model="editForm.logical_operator">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="edit-group">Group ID</Label>
<Input
id="edit-group"
v-model.number="editForm.group_id"
type="number"
/>
</div>
</div>
<div class="space-y-2">
<Label for="edit-order">Order</Label>
<Input
id="edit-order"
v-model.number="editForm.order"
type="number"
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="edit-enabled"
v-model="editForm.enabled"
/>
<Label for="edit-enabled" class="cursor-pointer">
Enabled
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showEditDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="editForm.processing">
Update Condition
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,344 @@
<script setup>
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Badge } from "@/Components/ui/badge";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Plus, Pencil, Trash } from "lucide-vue-next";
const props = defineProps({
report: Object,
entities: Array,
});
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const editingEntity = ref(null);
const createForm = useForm({
model_class: "",
alias: "",
join_type: "base",
join_first: "",
join_operator: "=",
join_second: "",
order: 0,
});
const editForm = useForm({
model_class: "",
alias: "",
join_type: "base",
join_first: "",
join_operator: "=",
join_second: "",
order: 0,
});
function openCreateDialog() {
createForm.reset();
createForm.order = props.entities.length;
showCreateDialog.value = true;
}
function submitCreate() {
createForm.post(route("settings.reports.entities.store", props.report.id), {
preserveScroll: true,
onSuccess: () => {
showCreateDialog.value = false;
createForm.reset();
},
});
}
function openEditDialog(entity) {
editingEntity.value = entity;
editForm.model_class = entity.model_class;
editForm.alias = entity.alias || "";
editForm.join_type = entity.join_type;
editForm.join_first = entity.join_first || "";
editForm.join_operator = entity.join_operator || "=";
editForm.join_second = entity.join_second || "";
editForm.order = entity.order;
showEditDialog.value = true;
}
function submitEdit() {
editForm.put(route("settings.reports.entities.update", editingEntity.value.id), {
preserveScroll: true,
onSuccess: () => {
showEditDialog.value = false;
editForm.reset();
editingEntity.value = null;
},
});
}
function deleteEntity(entity) {
if (confirm("Are you sure you want to delete this entity?")) {
router.delete(route("settings.reports.entities.destroy", entity.id), {
preserveScroll: true,
});
}
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>Database Entities & Joins</CardTitle>
<CardDescription>
Configure which models/tables to query and how to join them
</CardDescription>
</div>
<Button @click="openCreateDialog">
<Plus class="mr-2 h-4 w-4" />
Add Entity
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="entities.length === 0" class="text-center py-8 text-gray-500">
No entities configured. Add a base entity to get started.
</div>
<div v-else class="space-y-3">
<div
v-for="entity in entities"
:key="entity.id"
class="flex items-start justify-between rounded-lg border p-4"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Badge :variant="entity.join_type === 'base' ? 'default' : 'secondary'">
{{ entity.join_type }}
</Badge>
<span class="font-mono text-sm">{{ entity.model_class }}</span>
<Badge v-if="entity.alias" variant="outline">as {{ entity.alias }}</Badge>
</div>
<div v-if="entity.join_type !== 'base'" class="text-sm text-gray-600 dark:text-gray-400">
{{ entity.join_first }} {{ entity.join_operator }} {{ entity.join_second }}
</div>
<div class="text-xs text-gray-500 mt-1">Order: {{ entity.order }}</div>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="icon" @click="openEditDialog(entity)">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="deleteEntity(entity)">
<Trash class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Entity</DialogTitle>
<DialogDescription>
Add a model/table to the report query
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="space-y-2">
<Label for="create-model">Model Class *</Label>
<Input
id="create-model"
v-model="createForm.model_class"
placeholder="App\Models\Contract"
required
/>
</div>
<div class="space-y-2">
<Label for="create-alias">Alias (optional)</Label>
<Input
id="create-alias"
v-model="createForm.alias"
placeholder="contracts"
/>
</div>
<div class="space-y-2">
<Label for="create-join-type">Join Type *</Label>
<Select v-model="createForm.join_type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">base (no join)</SelectItem>
<SelectItem value="join">join (INNER JOIN)</SelectItem>
<SelectItem value="leftJoin">leftJoin (LEFT JOIN)</SelectItem>
<SelectItem value="rightJoin">rightJoin (RIGHT JOIN)</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="createForm.join_type !== 'base'" class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label for="create-join-first">Join First Column *</Label>
<Input
id="create-join-first"
v-model="createForm.join_first"
placeholder="contracts.client_id"
/>
</div>
<div class="space-y-2">
<Label for="create-join-op">Operator *</Label>
<Select v-model="createForm.join_operator">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">></SelectItem>
<SelectItem value="<"><</SelectItem>
<SelectItem value=">=">>=</SelectItem>
<SelectItem value="<="><=</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="create-join-second">Join Second Column *</Label>
<Input
id="create-join-second"
v-model="createForm.join_second"
placeholder="clients.id"
/>
</div>
</div>
<div class="space-y-2">
<Label for="create-order">Order</Label>
<Input
id="create-order"
v-model.number="createForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="createForm.processing">
Add Entity
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Edit Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Entity</DialogTitle>
<DialogDescription>
Update entity configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitEdit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-model">Model Class *</Label>
<Input
id="edit-model"
v-model="editForm.model_class"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-alias">Alias (optional)</Label>
<Input
id="edit-alias"
v-model="editForm.alias"
/>
</div>
<div class="space-y-2">
<Label for="edit-join-type">Join Type *</Label>
<Select v-model="editForm.join_type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">base (no join)</SelectItem>
<SelectItem value="join">join (INNER JOIN)</SelectItem>
<SelectItem value="leftJoin">leftJoin (LEFT JOIN)</SelectItem>
<SelectItem value="rightJoin">rightJoin (RIGHT JOIN)</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="editForm.join_type !== 'base'" class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label for="edit-join-first">Join First Column *</Label>
<Input
id="edit-join-first"
v-model="editForm.join_first"
/>
</div>
<div class="space-y-2">
<Label for="edit-join-op">Operator *</Label>
<Select v-model="editForm.join_operator">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">></SelectItem>
<SelectItem value="<"><</SelectItem>
<SelectItem value=">=">>=</SelectItem>
<SelectItem value="<="><=</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="edit-join-second">Join Second Column *</Label>
<Input
id="edit-join-second"
v-model="editForm.join_second"
/>
</div>
</div>
<div class="space-y-2">
<Label for="edit-order">Order</Label>
<Input
id="edit-order"
v-model.number="editForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showEditDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="editForm.processing">
Update Entity
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,344 @@
<script setup>
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Plus, Pencil, Trash } from "lucide-vue-next";
const props = defineProps({
report: Object,
filters: Array,
});
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const editingFilter = ref(null);
const createForm = useForm({
key: "",
label: "",
type: "string",
nullable: true,
default_value: "",
data_source: "",
order: 0,
});
const editForm = useForm({
key: "",
label: "",
type: "string",
nullable: true,
default_value: "",
data_source: "",
order: 0,
});
function openCreateDialog() {
createForm.reset();
createForm.order = props.filters.length;
showCreateDialog.value = true;
}
function submitCreate() {
createForm.post(route("settings.reports.filters.store", props.report.id), {
preserveScroll: true,
onSuccess: () => {
showCreateDialog.value = false;
createForm.reset();
},
});
}
function openEditDialog(filter) {
editingFilter.value = filter;
editForm.key = filter.key;
editForm.label = filter.label;
editForm.type = filter.type;
editForm.nullable = filter.nullable;
editForm.default_value = filter.default_value || "";
editForm.data_source = filter.data_source || "";
editForm.order = filter.order;
showEditDialog.value = true;
}
function submitEdit() {
editForm.put(route("settings.reports.filters.update", editingFilter.value.id), {
preserveScroll: true,
onSuccess: () => {
showEditDialog.value = false;
editForm.reset();
editingFilter.value = null;
},
});
}
function deleteFilter(filter) {
if (confirm("Are you sure you want to delete this filter?")) {
router.delete(route("settings.reports.filters.destroy", filter.id), {
preserveScroll: true,
});
}
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>Report Filters</CardTitle>
<CardDescription>
Define input parameters that users can provide to filter the report
</CardDescription>
</div>
<Button @click="openCreateDialog">
<Plus class="mr-2 h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="filters.length === 0" class="text-center py-8 text-gray-500">
No filters configured. Add filters to allow users to filter report results.
</div>
<div v-else class="space-y-3">
<div
v-for="filter in filters"
:key="filter.id"
class="flex items-start justify-between rounded-lg border p-4"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="font-semibold">{{ filter.label }}</span>
<Badge variant="outline">{{ filter.type }}</Badge>
<Badge v-if="filter.nullable" variant="secondary">nullable</Badge>
<Badge v-if="filter.data_source" variant="secondary">{{ filter.data_source }}</Badge>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
{{ filter.key }}
</div>
<div v-if="filter.default_value" class="text-sm text-gray-500 mt-1">
Default: {{ filter.default_value }}
</div>
<div class="text-xs text-gray-500 mt-1">Order: {{ filter.order }}</div>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="icon" @click="openEditDialog(filter)">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="deleteFilter(filter)">
<Trash class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Filter</DialogTitle>
<DialogDescription>
Add a new filter parameter for the report
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="create-key">Key *</Label>
<Input
id="create-key"
v-model="createForm.key"
placeholder="client_uuid"
required
/>
</div>
<div class="space-y-2">
<Label for="create-label">Label *</Label>
<Input
id="create-label"
v-model="createForm.label"
placeholder="Client"
required
/>
</div>
</div>
<div class="space-y-2">
<Label for="create-type">Type *</Label>
<Select v-model="createForm.type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">string</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
<SelectItem value="select">select</SelectItem>
<SelectItem value="select:client">select:client</SelectItem>
<SelectItem value="select:user">select:user</SelectItem>
<SelectItem value="multiselect">multiselect</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="create-data-source">Data Source (optional)</Label>
<Input
id="create-data-source"
v-model="createForm.data_source"
placeholder="clients, users, segments..."
/>
<p class="text-xs text-gray-500">For dynamic selects</p>
</div>
<div class="space-y-2">
<Label for="create-default">Default Value (optional)</Label>
<Input
id="create-default"
v-model="createForm.default_value"
placeholder="Default value..."
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="create-nullable"
v-model="createForm.nullable"
/>
<Label for="create-nullable" class="cursor-pointer">
Nullable (filter is optional)
</Label>
</div>
<div class="space-y-2">
<Label for="create-order">Order</Label>
<Input
id="create-order"
v-model.number="createForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="createForm.processing">
Add Filter
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Edit Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Filter</DialogTitle>
<DialogDescription>
Update filter configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitEdit" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-key">Key *</Label>
<Input
id="edit-key"
v-model="editForm.key"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-label">Label *</Label>
<Input
id="edit-label"
v-model="editForm.label"
required
/>
</div>
</div>
<div class="space-y-2">
<Label for="edit-type">Type *</Label>
<Select v-model="editForm.type">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">string</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
<SelectItem value="select">select</SelectItem>
<SelectItem value="select:client">select:client</SelectItem>
<SelectItem value="select:user">select:user</SelectItem>
<SelectItem value="multiselect">multiselect</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="edit-data-source">Data Source (optional)</Label>
<Input
id="edit-data-source"
v-model="editForm.data_source"
/>
</div>
<div class="space-y-2">
<Label for="edit-default">Default Value (optional)</Label>
<Input
id="edit-default"
v-model="editForm.default_value"
/>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="edit-nullable"
v-model="editForm.nullable"
/>
<Label for="edit-nullable" class="cursor-pointer">
Nullable (filter is optional)
</Label>
</div>
<div class="space-y-2">
<Label for="edit-order">Order</Label>
<Input
id="edit-order"
v-model.number="editForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showEditDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="editForm.processing">
Update Filter
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,238 @@
<script setup>
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Badge } from "@/Components/ui/badge";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Plus, Pencil, Trash, ArrowUp, ArrowDown } from "lucide-vue-next";
const props = defineProps({
report: Object,
orders: Array,
});
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const editingOrder = ref(null);
const createForm = useForm({
column: "",
direction: "ASC",
order: 0,
});
const editForm = useForm({
column: "",
direction: "ASC",
order: 0,
});
function openCreateDialog() {
createForm.reset();
createForm.order = props.orders.length;
showCreateDialog.value = true;
}
function submitCreate() {
createForm.post(route("settings.reports.orders.store", props.report.id), {
preserveScroll: true,
onSuccess: () => {
showCreateDialog.value = false;
createForm.reset();
},
});
}
function openEditDialog(order) {
editingOrder.value = order;
editForm.column = order.column;
editForm.direction = order.direction;
editForm.order = order.order;
showEditDialog.value = true;
}
function submitEdit() {
editForm.put(route("settings.reports.orders.update", editingOrder.value.id), {
preserveScroll: true,
onSuccess: () => {
showEditDialog.value = false;
editForm.reset();
editingOrder.value = null;
},
});
}
function deleteOrder(order) {
if (confirm("Are you sure you want to delete this order clause?")) {
router.delete(route("settings.reports.orders.destroy", order.id), {
preserveScroll: true,
});
}
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>ORDER BY Clauses</CardTitle>
<CardDescription>
Define how to sort the report results
</CardDescription>
</div>
<Button @click="openCreateDialog">
<Plus class="mr-2 h-4 w-4" />
Add Order
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="orders.length === 0" class="text-center py-8 text-gray-500">
No order clauses configured. Add ORDER BY clauses to sort results.
</div>
<div v-else class="space-y-3">
<div
v-for="orderClause in orders"
:key="orderClause.id"
class="flex items-start justify-between rounded-lg border p-4"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<ArrowUp v-if="orderClause.direction === 'ASC'" class="h-4 w-4 text-blue-600" />
<ArrowDown v-else class="h-4 w-4 text-orange-600" />
<span class="font-mono text-sm">{{ orderClause.column }}</span>
<Badge :variant="orderClause.direction === 'ASC' ? 'default' : 'secondary'">
{{ orderClause.direction }}
</Badge>
</div>
<div class="text-xs text-gray-500">Order: {{ orderClause.order }}</div>
</div>
<div class="flex gap-2">
<Button variant="ghost" size="icon" @click="openEditDialog(orderClause)">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" @click="deleteOrder(orderClause)">
<Trash class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Add Order Clause</DialogTitle>
<DialogDescription>
Add a new ORDER BY clause
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitCreate" class="space-y-4">
<div class="space-y-2">
<Label for="create-column">Column *</Label>
<Input
id="create-column"
v-model="createForm.column"
placeholder="contracts.start_date"
required
/>
</div>
<div class="space-y-2">
<Label for="create-direction">Direction *</Label>
<Select v-model="createForm.direction">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">ASC (Ascending)</SelectItem>
<SelectItem value="DESC">DESC (Descending)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="create-order">Order</Label>
<Input
id="create-order"
v-model.number="createForm.order"
type="number"
/>
<p class="text-xs text-gray-500">Determines sort priority (lower = higher priority)</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="createForm.processing">
Add Order
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Edit Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Order Clause</DialogTitle>
<DialogDescription>
Update order clause configuration
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submitEdit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-column">Column *</Label>
<Input
id="edit-column"
v-model="editForm.column"
required
/>
</div>
<div class="space-y-2">
<Label for="edit-direction">Direction *</Label>
<Select v-model="editForm.direction">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">ASC (Ascending)</SelectItem>
<SelectItem value="DESC">DESC (Descending)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="edit-order">Order</Label>
<Input
id="edit-order"
v-model.number="editForm.order"
type="number"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showEditDialog = false">
Cancel
</Button>
<Button type="submit" :disabled="editForm.processing">
Update Order
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -2,12 +2,17 @@
import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import PrimaryButton from "@/Components/PrimaryButton.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { LayoutGrid } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog";
import { Input } from "@/Components/ui/input";
import { Checkbox } from "@/Components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import InputLabel from "@/Components/InputLabel.vue";
import InputError from "@/Components/InputError.vue";
import TextInput from "@/Components/TextInput.vue";
const props = defineProps({
segments: Array,
@ -79,144 +84,150 @@ const update = () => {
<template #header></template>
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Segments</h2>
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<LayoutGrid :size="18" />
<CardTitle class="uppercase">Segments</CardTitle>
</div>
<Button @click="openCreate">+ New</Button>
</div>
</template>
<div class="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Active</TableHead>
<TableHead>Exclude</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="s in segments" :key="s.id">
<TableCell>{{ s.id }}</TableCell>
<TableCell>{{ s.name }}</TableCell>
<TableCell>{{ s.description }}</TableCell>
<TableCell>
<Badge :variant="s.active ? 'default' : 'secondary'">
{{ s.active ? "Yes" : "No" }}
</Badge>
</TableCell>
<TableCell>
<Badge :variant="s.exclude ? 'default' : 'secondary'">
{{ s.exclude ? "Yes" : "No" }}
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" @click="openEdit(s)">
Edit
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<CreateDialog
:show="showCreate"
title="New Segment"
confirm-text="Create"
:processing="createForm.processing"
@close="closeCreate"
@confirm="store"
>
<form @submit.prevent="store" class="space-y-4">
<!-- Create Dialog -->
<Dialog v-model:open="showCreate">
<DialogContent>
<DialogHeader>
<DialogTitle>New Segment</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="nameCreate" value="Name" />
<TextInput
<InputLabel for="nameCreate">Name</InputLabel>
<Input
id="nameCreate"
v-model="createForm.name"
type="text"
class="mt-1 block w-full"
/>
<InputError :message="createForm.errors.name" class="mt-1" />
</div>
<div>
<InputLabel for="descCreate" value="Description" />
<TextInput
<InputLabel for="descCreate">Description</InputLabel>
<Input
id="descCreate"
v-model="createForm.description"
type="text"
class="mt-1 block w-full"
/>
<InputError :message="createForm.errors.description" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<input id="activeCreate" type="checkbox" v-model="createForm.active" />
<label for="activeCreate">Active</label>
<Checkbox id="activeCreate" v-model="createForm.active" />
<InputLabel for="activeCreate" class="text-sm font-normal cursor-pointer">
Active
</InputLabel>
</div>
<div class="flex items-center gap-2">
<input
id="excludeCreate"
type="checkbox"
v-model="createForm.exclude"
/>
<label for="excludeCreate">Exclude</label>
<Checkbox id="excludeCreate" v-model="createForm.exclude" />
<InputLabel for="excludeCreate" class="text-sm font-normal cursor-pointer">
Exclude
</InputLabel>
</div>
</form>
</CreateDialog>
</div>
<DialogFooter>
<Button variant="outline" @click="closeCreate">Cancel</Button>
<Button @click="store" :disabled="createForm.processing">Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UpdateDialog
:show="showEdit"
title="Edit Segment"
confirm-text="Save"
:processing="editForm.processing"
@close="closeEdit"
@confirm="update"
>
<form @submit.prevent="update" class="space-y-4">
<!-- Edit Dialog -->
<Dialog v-model:open="showEdit">
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Segment</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="nameEdit" value="Name" />
<TextInput
<InputLabel for="nameEdit">Name</InputLabel>
<Input
id="nameEdit"
v-model="editForm.name"
type="text"
class="mt-1 block w-full"
/>
<InputError :message="editForm.errors.name" class="mt-1" />
</div>
<div>
<InputLabel for="descEdit" value="Description" />
<TextInput
<InputLabel for="descEdit">Description</InputLabel>
<Input
id="descEdit"
v-model="editForm.description"
type="text"
class="mt-1 block w-full"
/>
<InputError :message="editForm.errors.description" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<input id="activeEdit" type="checkbox" v-model="editForm.active" />
<label for="activeEdit">Active</label>
<Checkbox id="activeEdit" v-model="editForm.active" />
<InputLabel for="activeEdit" class="text-sm font-normal cursor-pointer">
Active
</InputLabel>
</div>
<div class="flex items-center gap-2">
<input id="excludeEdit" type="checkbox" v-model="editForm.exclude" />
<label for="excludeEdit">Exclude</label>
<Checkbox id="excludeEdit" v-model="editForm.exclude" />
<InputLabel for="excludeEdit" class="text-sm font-normal cursor-pointer">
Exclude
</InputLabel>
</div>
</form>
</UpdateDialog>
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">ID</th>
<th class="py-2 pr-4">Name</th>
<th class="py-2 pr-4">Description</th>
<th class="py-2 pr-4">Active</th>
<th class="py-2 pr-4">Exclude</th>
<th class="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in segments" :key="s.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ s.id }}</td>
<td class="py-2 pr-4">{{ s.name }}</td>
<td class="py-2 pr-4">{{ s.description }}</td>
<td class="py-2 pr-4">
<span class="inline-flex items-center gap-1">
<span
:class="s.active ? 'bg-green-500' : 'bg-gray-400'"
class="inline-block w-2 h-2 rounded-full"
></span>
{{ s.active ? "Yes" : "No" }}
</span>
</td>
<td class="py-2 pr-4">
<span class="inline-flex items-center gap-1">
<span
:class="s.exclude ? 'bg-green-500' : 'bg-gray-400'"
class="inline-block w-2 h-2 rounded-full"
></span>
{{ s.exclude ? "Yes" : "No" }}
</span>
</td>
<td class="py-2 pr-4">
<button
class="text-indigo-600 hover:text-indigo-800"
@click="openEdit(s)"
>
Edit
</button>
<!-- Delete intentionally skipped as requested -->
</td>
</tr>
</tbody>
</table>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeEdit">Cancel</Button>
<Button @click="update" :disabled="editForm.processing">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AppCard>
</div>
</div>
</AppLayout>

View File

@ -1,9 +1,12 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref } from "vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Workflow } from "lucide-vue-next";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/Components/ui/tabs";
import ActionTable from "../Partials/ActionTable.vue";
import DecisionTable from "../Partials/DecisionTable.vue";
import ActionTable from "./Partials/ActionTable.vue";
import DecisionTable from "./Partials/DecisionTable.vue";
const props = defineProps({
actions: Array,
@ -21,11 +24,23 @@ const activeTab = ref("actions");
<template #header></template>
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<Tabs v-model="activeTab" class="w-full">
<TabsList class="w-full justify-start border-b rounded-none bg-transparent p-0">
<TabsTrigger value="actions" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary">Akcije</TabsTrigger>
<TabsTrigger value="decisions" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary">Odločitve</TabsTrigger>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<Workflow :size="18" />
<CardTitle class="uppercase">Workflow</CardTitle>
</div>
</template>
<Tabs v-model="activeTab" class="border-t">
<TabsList class="border-b w-full flex flex-row justify-baseline rounded-none">
<TabsTrigger value="actions">Akcije</TabsTrigger>
<TabsTrigger value="decisions">Odločitve</TabsTrigger>
</TabsList>
<TabsContent value="actions" class="mt-0">
<ActionTable
@ -45,7 +60,7 @@ const activeTab = ref("actions");
/>
</TabsContent>
</Tabs>
</div>
</AppCard>
</div>
</div>
</AppLayout>

View File

@ -0,0 +1,406 @@
<script setup>
// flowbite-vue table imports removed; using DataTableClient
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogFooter,
} from "@/Components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { computed, onMounted, ref } from "vue";
import { router, useForm } from "@inertiajs/vue3";
import InputLabel from "@/Components/InputLabel.vue";
import { Input } from "@/Components/ui/input";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { Button } from "@/Components/ui/button";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
const props = defineProps({
actions: Array,
decisions: Array,
segments: Array,
});
const drawerEdit = ref(false);
const drawerCreate = ref(false);
const showDelete = ref(false);
const toDelete = ref(null);
const search = ref("");
const selectedSegment = ref(null);
const selectOptions = computed(() =>
props.decisions.map((d) => ({
label: d.name,
value: d.id,
}))
);
const segmentOptions = computed(() =>
props.segments.map((d) => ({
label: d.name,
value: d.id,
}))
);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true, class: "w-16" },
{ key: "name", label: "Ime", sortable: true },
{ key: "color_tag", label: "Barva", sortable: false },
{ key: "segment", label: "Segment", sortable: false },
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
];
const form = useForm({
id: 0,
name: "",
color_tag: "",
segment_id: null,
decisions: [],
});
const createForm = useForm({
name: "",
color_tag: "",
segment_id: null,
decisions: [],
});
const openEditDrawer = (item) => {
form.decisions = [];
form.id = item.id;
form.name = item.name;
form.color_tag = item.color_tag;
form.segment_id = item.segment ? item.segment.id : null;
drawerEdit.value = true;
// AppMultiSelect expects array of values
form.decisions = item.decisions.map((d) => d.id);
};
const closeEditDrawer = () => {
drawerEdit.value = false;
form.reset();
};
const openCreateDrawer = () => {
createForm.reset();
drawerCreate.value = true;
};
const closeCreateDrawer = () => {
drawerCreate.value = false;
createForm.reset();
};
const filtered = computed(() => {
const term = search.value?.toLowerCase() ?? "";
return (props.actions || []).filter((a) => {
const matchesSearch =
!term ||
a.name?.toLowerCase().includes(term) ||
a.color_tag?.toLowerCase().includes(term);
const matchesSegment =
!selectedSegment.value || a.segment?.id === selectedSegment.value;
return matchesSearch && matchesSegment;
});
});
const update = () => {
// Transform decisions from array of IDs to array of objects
const decisionsPayload = form.decisions
.map((id) => {
const decision = props.decisions.find((d) => d.id === Number(id) || d.id === id);
if (!decision) {
console.warn("Decision not found for id:", id);
return null;
}
return { id: decision.id, name: decision.name };
})
.filter(Boolean); // Remove null entries
form
.transform((data) => ({
...data,
decisions: decisionsPayload,
}))
.put(route("settings.actions.update", { id: form.id }), {
onSuccess: () => {
closeEditDrawer();
},
});
};
const store = () => {
// Transform decisions from array of IDs to array of objects
const decisionsPayload = createForm.decisions
.map((id) => {
const decision = props.decisions.find((d) => d.id === Number(id) || d.id === id);
if (!decision) {
console.warn("Decision not found for id:", id);
return null;
}
return { id: decision.id, name: decision.name };
})
.filter(Boolean); // Remove null entries
createForm
.transform((data) => ({
...data,
decisions: decisionsPayload,
}))
.post(route("settings.actions.store"), {
onSuccess: () => {
closeCreateDrawer();
},
});
};
const confirmDelete = (action) => {
toDelete.value = action;
showDelete.value = true;
};
const cancelDelete = () => {
toDelete.value = null;
showDelete.value = false;
};
const destroyAction = () => {
if (!toDelete.value) return;
router.delete(route("settings.actions.destroy", { id: toDelete.value.id }), {
preserveScroll: true,
onFinish: () => cancelDelete(),
});
};
</script>
<template>
<div class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex gap-3 items-center">
<AppPopover align="start" side="bottom" content-class="w-80">
<template #trigger>
<Button variant="outline" size="sm">
<FilterIcon class="w-4 h-4 mr-2" />
Filtri
</Button>
</template>
<div class="p-1">
<div>
<InputLabel for="searchFilter" value="Iskanje" class="mb-1" />
<Input
id="searchFilter"
v-model="search"
placeholder="Iskanje..."
class="w-full"
/>
</div>
<div>
<InputLabel for="segmentFilter" value="Segment" class="mb-1" />
<AppCombobox
id="segmentFilter"
v-model="selectedSegment"
:items="segmentOptions"
placeholder="Filter po segmentu"
button-class="w-full"
/>
</div>
</div>
</AppPopover>
</div>
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
</div>
<div>
<DataTableClient
:columns="columns"
:rows="filtered"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-color_tag="{ row }">
<div class="flex items-center gap-2">
<span
v-if="row.color_tag"
class="inline-block h-4 w-4 rounded"
:style="{ backgroundColor: row.color_tag }"
></span>
<span>{{ row.color_tag || "" }}</span>
</div>
</template>
<template #cell-decisions="{ row }">
{{ row.decisions?.length ?? 0 }}
</template>
<template #cell-segment="{ row }">
<span>
{{ row.segment?.name || "" }}
</span>
</template>
<template #actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openEditDrawer(row)">
<Pencil class="w-4 h-4 mr-2" />
Uredi
</DropdownMenuItem>
<DropdownMenuItem
:disabled="(row.activities_count ?? 0) > 0"
@click="confirmDelete(row)"
class="text-red-600 focus:text-red-600"
>
<Trash class="w-4 h-4 mr-2" />
Izbriši
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableClient>
</div>
<Dialog v-model:open="drawerEdit">
<DialogContent>
<DialogHeader>
<DialogTitle>Spremeni akcijo</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="name">Ime</InputLabel>
<Input id="name" ref="nameInput" v-model="form.name" type="text" />
</div>
<div>
<InputLabel for="colorTag">Barva</InputLabel>
<div class="mt-1">
<InlineColorPicker v-model="form.color_tag" />
</div>
</div>
<div>
<InputLabel for="segmentEdit">Segment</InputLabel>
<AppCombobox
id="segmentEdit"
v-model="form.segment_id"
:items="segmentOptions"
placeholder="Izberi segment"
button-class="w-full"
/>
</div>
<div>
<InputLabel for="decisions">Odločitve</InputLabel>
<AppMultiSelect
id="decisions"
v-model="form.decisions"
:items="selectOptions"
placeholder="Dodaj odločitev"
content-class="p-0 w-full"
/>
</div>
<div v-if="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeEditDrawer">Cancel</Button>
<Button @click="update" :disabled="form.processing">Shrani</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="drawerCreate">
<DialogContent>
<DialogHeader>
<DialogTitle>Dodaj akcijo</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="nameCreate">Ime</InputLabel>
<Input id="nameCreate" v-model="createForm.name" type="text" />
</div>
<div>
<InputLabel for="colorTagCreate">Barva</InputLabel>
<div class="mt-1">
<InlineColorPicker v-model="createForm.color_tag" />
</div>
</div>
<div>
<InputLabel for="segmentCreate">Segment</InputLabel>
<AppCombobox
id="segmentCreate"
v-model="createForm.segment_id"
:items="segmentOptions"
placeholder="Izberi segment"
button-class="w-full"
/>
</div>
<div>
<InputLabel for="decisionsCreate">Odločitve</InputLabel>
<AppMultiSelect
id="decisionsCreate"
v-model="createForm.decisions"
:items="selectOptions"
placeholder="Dodaj odločitev"
content-class="p-0 w-full"
/>
</div>
<div v-if="createForm.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeCreateDrawer">Cancel</Button>
<Button @click="store" :disabled="createForm.processing">Dodaj</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog v-model:open="showDelete">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete action</AlertDialogTitle>
</AlertDialogHeader>
<div class="text-sm text-muted-foreground">
Are you sure you want to delete action "{{ toDelete?.name }}"? This cannot be
undone.
</div>
<AlertDialogFooter>
<Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="destroyAction">Delete</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@ -1,19 +1,46 @@
<script setup>
// flowbite-vue table imports removed; using DataTableClient
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogFooter,
} from "@/Components/ui/alert-dialog";
import { computed, onMounted, ref, watch, nextTick } from "vue";
import { router, useForm } from "@inertiajs/vue3";
import InputLabel from "@/Components/InputLabel.vue";
import TextInput from "@/Components/TextInput.vue";
import Multiselect from "vue-multiselect";
import PrimaryButton from "@/Components/PrimaryButton.vue";
import ActionMessage from "@/Components/ActionMessage.vue";
import { Input } from "@/Components/ui/input";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/Components/ui/select";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { Button } from "@/Components/ui/button";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
import Dropdown from "@/Components/Dropdown.vue";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
const props = defineProps({
decisions: Array,
@ -42,12 +69,12 @@ const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true, class: "w-16" },
{ key: "id", label: "#", sortable: true },
{ key: "name", label: "Ime", sortable: true },
{ key: "color_tag", label: "Barva", sortable: false },
{ key: "events", label: "Dogodki", sortable: false, class: "w-40" },
{ key: "belongs", label: "Pripada akcijam", sortable: false, class: "w-40" },
{ key: "auto_mail", label: "Auto mail", sortable: false, class: "w-46" },
{ key: "events", label: "Dogodki", sortable: false },
{ key: "belongs", label: "Pripada akcijam", sortable: false },
{ key: "auto_mail", label: "Auto mail", sortable: false },
];
const form = useForm({
@ -119,12 +146,7 @@ const openEditDrawer = (item) => {
});
drawerEdit.value = true;
item.actions.forEach((a) => {
form.actions.push({
name: a.name,
id: a.id,
});
});
form.actions = item.actions.map((a) => a.id);
};
const closeEditDrawer = () => {
@ -145,8 +167,8 @@ const closeCreateDrawer = () => {
onMounted(() => {
props.actions.forEach((a) => {
actionOptions.value.push({
name: a.name,
id: a.id,
label: a.name,
value: a.id,
});
});
});
@ -217,7 +239,7 @@ function tryAdoptRaw(ev) {
const filtered = computed(() => {
const term = search.value?.toLowerCase() ?? "";
const tplId = selectedTemplateId.value ? Number(selectedTemplateId.value) : null;
const evIdSet = new Set((selectedEvents.value || []).map((e) => Number(e.id)));
const evIdSet = new Set((selectedEvents.value || []).map((e) => Number(e)));
return (props.decisions || []).filter((d) => {
const matchesSearch =
!term ||
@ -241,7 +263,22 @@ const update = () => {
return;
}
form.put(route("settings.decisions.update", { id: form.id }), {
// Transform actions from array of IDs to array of objects
const actionsPayload = form.actions
.map(id => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
if (!action) {
console.warn('Action not found for id:', id);
return null;
}
return { id: action.id, name: action.name };
})
.filter(Boolean); // Remove null entries
form.transform((data) => ({
...data,
actions: actionsPayload
})).put(route("settings.decisions.update", { id: form.id }), {
onSuccess: () => {
closeEditDrawer();
},
@ -260,7 +297,22 @@ const store = () => {
return;
}
createForm.post(route("settings.decisions.store"), {
// Transform actions from array of IDs to array of objects
const actionsPayload = createForm.actions
.map(id => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
if (!action) {
console.warn('Action not found for id:', id);
return null;
}
return { id: action.id, name: action.name };
})
.filter(Boolean); // Remove null entries
createForm.transform((data) => ({
...data,
actions: actionsPayload
})).post(route("settings.decisions.store"), {
onSuccess: () => {
closeCreateDrawer();
},
@ -351,68 +403,65 @@ const destroyDecision = () => {
</script>
<template>
<div class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full bg-gray-50 border rounded-md p-3">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-center">
<!-- Search -->
<div class="relative sm:col-span-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
<div class="flex gap-3 items-center">
<AppPopover align="start" side="bottom" content-class="w-96">
<template #trigger>
<Button variant="outline" size="sm">
<FilterIcon class="w-4 h-4 mr-2" />
Filtri
</Button>
</template>
<div class="space-y-3 p-1">
<div>
<InputLabel for="searchFilter" value="Iskanje" class="mb-1" />
<Input
id="searchFilter"
v-model="search"
placeholder="Iskanje..."
class="w-full"
/>
</svg>
<TextInput v-model="search" placeholder="Iskanje..." class="w-full pl-9 h-10" />
</div>
<!-- Template select -->
<div class="sm:col-span-3">
<select
v-model="selectedTemplateId"
class="block w-full h-10 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null">Vse predloge</option>
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</option>
</select>
</div>
<!-- Events multiselect -->
<div class="sm:col-span-4">
<multiselect
v-model="selectedEvents"
:options="availableEvents"
:multiple="true"
track-by="id"
label="name"
placeholder="Filtriraj po dogodkih"
class="w-full"
/>
</div>
<!-- Only auto mail -->
<div class="sm:col-span-2">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
v-model="onlyAutoMail"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 h-4 w-4"
</div>
<div>
<InputLabel for="templateFilter" value="Email predloga" class="mb-1" />
<Select v-model="selectedTemplateId">
<SelectTrigger id="templateFilter" class="w-full">
<SelectValue placeholder="Vse predloge" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vse predloge</SelectItem>
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<InputLabel for="eventsFilter" value="Dogodki" class="mb-1" />
<AppMultiSelect
id="eventsFilter"
v-model="selectedEvents"
:items="availableEvents.map((e) => ({ value: e.id, label: e.name }))"
placeholder="Filtriraj po dogodkih"
class="w-full"
/>
Samo auto mail
</label>
</div>
<div class="flex items-center gap-2">
<Checkbox id="onlyAutoMailFilter" v-model="onlyAutoMail" />
<InputLabel
for="onlyAutoMailFilter"
class="text-sm font-normal cursor-pointer"
>
Samo auto mail
</InputLabel>
</div>
</div>
</div>
</AppPopover>
</div>
<div class="flex-shrink-0">
<PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
<div class="shrink-0">
<Button @click="openCreateDrawer">+ Dodaj odločitev</Button>
</div>
</div>
<div class="px-4 pb-4">
<div>
<DataTableClient
:columns="columns"
:rows="filtered"
@ -421,6 +470,7 @@ const destroyDecision = () => {
:page="page"
:pageSize="pageSize"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
@ -496,63 +546,61 @@ const destroyDecision = () => {
</div>
</template>
<template #actions="{ row }">
<button class="px-2" @click="openEditDrawer(row)">
<EditIcon size="md" css="text-gray-500" />
</button>
<button
class="px-2 disabled:opacity-40"
:disabled="(row.activities_count ?? 0) > 0"
@click="confirmDelete(row)"
>
<TrashBinIcon size="md" css="text-red-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openEditDrawer(row)">
<Pencil class="w-4 h-4 mr-2" />
Uredi
</DropdownMenuItem>
<DropdownMenuItem
:disabled="(row.activities_count ?? 0) > 0"
@click="confirmDelete(row)"
class="text-red-600 focus:text-red-600"
>
<Trash class="w-4 h-4 mr-2" />
Izbriši
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableClient>
</div>
<UpdateDialog
:show="drawerEdit"
title="Spremeni odločitev"
confirm-text="Shrani"
:processing="form.processing"
:disabled="!eventsValidEdit"
@close="closeEditDrawer"
@confirm="update"
>
<form @submit.prevent="update">
<div class="col-span-6 sm:col-span-4">
<InputLabel for="name" value="Ime" />
<TextInput
id="name"
v-model="form.name"
type="text"
class="mt-1 block w-full"
autocomplete="name"
/>
<Dialog v-model:open="drawerEdit">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Spremeni odločitev</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="name">Ime</InputLabel>
<Input id="name" v-model="form.name" type="text" />
</div>
<div class="mt-4 flex items-center gap-2">
<input
id="autoMailEdit"
type="checkbox"
v-model="form.auto_mail"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<label for="autoMailEdit" class="text-sm">Samodejna pošta (auto mail)</label>
<Checkbox id="autoMailEdit" v-model="form.auto_mail" />
<InputLabel for="autoMailEdit" class="text-sm font-normal cursor-pointer">
Samodejna pošta (auto mail)
</InputLabel>
</div>
<div class="col-span-6 sm:col-span-4 mt-2">
<InputLabel for="emailTemplateEdit" value="Email predloga" />
<select
id="emailTemplateEdit"
v-model="form.email_template_id"
:disabled="!form.auto_mail"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<option :value="null"> Brez </option>
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</option>
</select>
<Select v-model="form.email_template_id" :disabled="!form.auto_mail">
<SelectTrigger id="emailTemplateEdit" class="w-full">
<SelectValue placeholder="— Brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Brez </SelectItem>
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="form.email_template_id" class="text-xs text-gray-500 mt-1">
<span
v-if="
@ -574,16 +622,11 @@ const destroyDecision = () => {
<div class="col-span-6 sm:col-span-4">
<InputLabel for="actionsSelect" value="Akcije" />
<multiselect
<AppMultiSelect
id="actionsSelect"
v-model="form.actions"
:options="actionOptions"
:multiple="true"
track-by="id"
:taggable="true"
:items="actionOptions"
placeholder="Dodaj akcijo"
:append-to-body="true"
label="name"
/>
</div>
@ -595,43 +638,44 @@ const destroyDecision = () => {
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<InputLabel :for="`event-${idx}`" value="Dogodek" />
<select
:id="`event-${idx}`"
v-model.number="ev.id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
@change="onEventChange(ev)"
>
<option :value="null"> Izberi </option>
<option v-for="opt in availableEvents" :key="opt.id" :value="opt.id">
{{ opt.name || opt.key || `#${opt.id}` }}
</option>
</select>
<Select v-model="ev.id" @update:model-value="onEventChange(ev)">
<SelectTrigger :id="`event-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Izberi </SelectItem>
<SelectItem
v-for="opt in availableEvents"
:key="opt.id"
:value="opt.id"
>
{{ opt.name || opt.key || `#${opt.id}` }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="w-36">
<InputLabel :for="`order-${idx}`" value="Vrstni red" />
<TextInput
<InputLabel :for="`order-${idx}`">Vrstni red</InputLabel>
<Input
:id="`order-${idx}`"
v-model.number="ev.run_order"
type="number"
class="w-full"
/>
</div>
<div class="flex items-end gap-2">
<div class="flex items-center gap-2 self-end">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
v-model="ev.active"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<Checkbox v-model:checked="ev.active" />
Aktivno
</label>
<button
type="button"
class="text-red-600 text-sm"
<Button
variant="ghost"
size="icon"
class="text-red-600 hover:text-red-700 hover:bg-red-50"
@click="form.events.splice(idx, 1)"
>
Odstrani
</button>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
<div class="mt-3">
@ -639,16 +683,17 @@ const destroyDecision = () => {
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<InputLabel :for="`seg-${idx}`" value="Segment" />
<select
:id="`seg-${idx}`"
v-model.number="ev.config.segment_id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null"> Izberi segment </option>
<option v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
<Select v-model="ev.config.segment_id">
<SelectTrigger :id="`seg-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi segment —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Izberi segment </SelectItem>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="form.errors[`events.${idx}.config.segment_id`]"
class="text-xs text-red-600 mt-1"
@ -658,11 +703,7 @@ const destroyDecision = () => {
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm mt-6">
<input
type="checkbox"
v-model="ev.config.deactivate_previous"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<Checkbox v-model:checked="ev.config.deactivate_previous" />
Deaktiviraj prejšnje
</label>
</div>
@ -672,16 +713,21 @@ const destroyDecision = () => {
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<InputLabel :for="`as-${idx}`" value="Archive setting" />
<select
:id="`as-${idx}`"
v-model.number="ev.config.archive_setting_id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null"> Izberi nastavitev </option>
<option v-for="a in archiveSettings" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
<Select v-model="ev.config.archive_setting_id">
<SelectTrigger :id="`as-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi nastavitev —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Izberi nastavitev </SelectItem>
<SelectItem
v-for="a in archiveSettings"
:key="a.id"
:value="a.id"
>
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="form.errors[`events.${idx}.config.archive_setting_id`]"
class="text-xs text-red-600 mt-1"
@ -691,11 +737,7 @@ const destroyDecision = () => {
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm mt-6">
<input
type="checkbox"
v-model="ev.config.reactivate"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<Checkbox v-model:checked="ev.config.reactivate" />
Reactivate namesto arhiva
</label>
</div>
@ -725,65 +767,63 @@ const destroyDecision = () => {
</div>
</div>
<div>
<PrimaryButton
<Button
type="button"
variant="outline"
@click="form.events.push(defaultEventPayload())"
>+ Dodaj dogodek</PrimaryButton
>+ Dodaj dogodek</Button
>
</div>
</div>
</div>
<div v-if="form.recentlySuccessful" class="mt-6 text-sm text-green-600">
<div v-if="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</div>
</form>
</UpdateDialog>
</div>
<DialogFooter>
<Button variant="outline" @click="closeEditDrawer">Cancel</Button>
<Button @click="update" :disabled="form.processing || !eventsValidEdit"
>Shrani</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<CreateDialog
:show="drawerCreate"
title="Dodaj odločitev"
confirm-text="Dodaj"
:processing="createForm.processing"
:disabled="!eventsValidCreate"
@close="closeCreateDrawer"
@confirm="store"
>
<form @submit.prevent="store">
<div class="col-span-6 sm:col-span-4">
<InputLabel for="nameCreate" value="Ime" />
<TextInput
id="nameCreate"
v-model="createForm.name"
type="text"
class="mt-1 block w-full"
autocomplete="name"
/>
<Dialog v-model:open="drawerCreate">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Dodaj odločitev</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
<InputLabel for="nameCreate">Ime</InputLabel>
<Input id="nameCreate" v-model="createForm.name" type="text" />
</div>
<div class="mt-4 flex items-center gap-2">
<input
id="autoMailCreate"
type="checkbox"
v-model="createForm.auto_mail"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<label for="autoMailCreate" class="text-sm">Samodejna pošta (auto mail)</label>
<Checkbox id="autoMailCreate" v-model="createForm.auto_mail" />
<InputLabel for="autoMailCreate" class="text-sm font-normal cursor-pointer">
Samodejna pošta (auto mail)
</InputLabel>
</div>
<div class="col-span-6 sm:col-span-4 mt-2">
<InputLabel for="emailTemplateCreate" value="Email predloga" />
<select
id="emailTemplateCreate"
<Select
v-model="createForm.email_template_id"
:disabled="!createForm.auto_mail"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<option :value="null"> Brez </option>
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</option>
</select>
<SelectTrigger id="emailTemplateCreate" class="w-full">
<SelectValue placeholder="— Brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Brez </SelectItem>
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="createForm.email_template_id" class="text-xs text-gray-500 mt-1">
<span
v-if="
@ -805,16 +845,11 @@ const destroyDecision = () => {
<div class="col-span-6 sm:col-span-4">
<InputLabel for="actionsCreate" value="Akcije" />
<multiselect
<AppMultiSelect
id="actionsCreate"
v-model="createForm.actions"
:options="actionOptions"
:multiple="true"
track-by="id"
:taggable="true"
:items="actionOptions"
placeholder="Dodaj akcijo"
:append-to-body="true"
label="name"
/>
</div>
@ -830,43 +865,44 @@ const destroyDecision = () => {
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<InputLabel :for="`cevent-${idx}`" value="Dogodek" />
<select
:id="`cevent-${idx}`"
v-model.number="ev.id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
@change="onEventChange(ev)"
>
<option :value="null"> Izberi </option>
<option v-for="opt in availableEvents" :key="opt.id" :value="opt.id">
{{ opt.name || opt.key || `#${opt.id}` }}
</option>
</select>
<Select v-model="ev.id" @update:model-value="onEventChange(ev)">
<SelectTrigger :id="`cevent-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Izberi </SelectItem>
<SelectItem
v-for="opt in availableEvents"
:key="opt.id"
:value="opt.id"
>
{{ opt.name || opt.key || `#${opt.id}` }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="w-36">
<InputLabel :for="`corder-${idx}`" value="Vrstni red" />
<TextInput
<InputLabel :for="`corder-${idx}`">Vrstni red</InputLabel>
<Input
:id="`corder-${idx}`"
v-model.number="ev.run_order"
type="number"
class="w-full"
/>
</div>
<div class="flex items-end gap-2">
<div class="flex items-center gap-2 self-end">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
v-model="ev.active"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<Checkbox v-model:checked="ev.active" />
Aktivno
</label>
<button
type="button"
class="text-red-600 text-sm"
<Button
variant="ghost"
size="icon"
class="text-red-600 hover:text-red-700 hover:bg-red-50"
@click="createForm.events.splice(idx, 1)"
>
Odstrani
</button>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
<div class="mt-3">
@ -874,16 +910,17 @@ const destroyDecision = () => {
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<InputLabel :for="`cseg-${idx}`" value="Segment" />
<select
:id="`cseg-${idx}`"
v-model.number="ev.config.segment_id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null"> Izberi segment </option>
<option v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
<Select v-model="ev.config.segment_id">
<SelectTrigger :id="`cseg-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi segment —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Izberi segment </SelectItem>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="createForm.errors[`events.${idx}.config.segment_id`]"
class="text-xs text-red-600 mt-1"
@ -893,11 +930,7 @@ const destroyDecision = () => {
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm mt-6">
<input
type="checkbox"
v-model="ev.config.deactivate_previous"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<Checkbox v-model:checked="ev.config.deactivate_previous" />
Deaktiviraj prejšnje
</label>
</div>
@ -907,16 +940,21 @@ const destroyDecision = () => {
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<InputLabel :for="`cas-${idx}`" value="Archive setting" />
<select
:id="`cas-${idx}`"
v-model.number="ev.config.archive_setting_id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null"> Izberi nastavitev </option>
<option v-for="a in archiveSettings" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
<Select v-model="ev.config.archive_setting_id">
<SelectTrigger :id="`cas-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi nastavitev —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Izberi nastavitev </SelectItem>
<SelectItem
v-for="a in archiveSettings"
:key="a.id"
:value="a.id"
>
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="
createForm.errors[`events.${idx}.config.archive_setting_id`]
@ -928,11 +966,7 @@ const destroyDecision = () => {
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm mt-6">
<input
type="checkbox"
v-model="ev.config.reactivate"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<Checkbox v-model:checked="ev.config.reactivate" />
Reactivate namesto arhiva
</label>
</div>
@ -961,35 +995,42 @@ const destroyDecision = () => {
</div>
</div>
<div>
<PrimaryButton
<Button
type="button"
variant="outline"
@click="createForm.events.push(defaultEventPayload())"
>+ Dodaj dogodek</PrimaryButton
>+ Dodaj dogodek</Button
>
</div>
</div>
</div>
<div v-if="createForm.recentlySuccessful" class="mt-6 text-sm text-green-600">
<div v-if="createForm.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</div>
</form>
</CreateDialog>
</div>
<DialogFooter>
<Button variant="outline" @click="closeCreateDrawer">Cancel</Button>
<Button @click="store" :disabled="createForm.processing || !eventsValidCreate"
>Dodaj</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmationModal :show="showDelete" @close="cancelDelete">
<template #title> Delete decision </template>
<template #content>
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be
undone.
</template>
<template #footer>
<button
@click="cancelDelete"
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2"
>
Cancel
</button>
<PrimaryButton @click="destroyDecision">Delete</PrimaryButton>
</template>
</ConfirmationModal>
<AlertDialog v-model:open="showDelete">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete decision</AlertDialogTitle>
</AlertDialogHeader>
<div class="text-sm text-muted-foreground">
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be
undone.
</div>
<AlertDialogFooter>
<Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="destroyDecision">Delete</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@ -24,6 +24,18 @@
$trail->push('Contract Configs', route('settings.contractConfigs.index'));
});
// Dashboard > Settings > Archive
Breadcrumbs::for('settings.archive.index', function (BreadcrumbTrail $trail): void {
$trail->parent('settings');
$trail->push('Arhiv', route('settings.archive.index'));
});
// Dashboard > Settings > Reports
Breadcrumbs::for('settings.reports.index', function (BreadcrumbTrail $trail): void {
$trail->parent('settings');
$trail->push('Reports', route('settings.reports.index'));
});
// Dashboard
Breadcrumbs::for('dashboard', function (BreadcrumbTrail $trail) {
$trail->push('Nadzorna plošča', route('dashboard'));
@ -109,6 +121,21 @@
$trail->push('Terenska dela', route('fieldjobs.index'));
});
// Dashboard > Reports
Breadcrumbs::for('reports.index', function (BreadcrumbTrail $trail) {
$trail->parent('dashboard');
$trail->push('Poročila', route('reports.index'));
});
// Dashboard > Reports > [Report]
Breadcrumbs::for('reports.show', function (BreadcrumbTrail $trail, string $slug) {
$trail->parent('reports.index');
$report = \App\Models\Report::where('slug', $slug)->first();
$trail->push($report?->name ?? $slug, route('reports.show', $slug));
});
// Dashboard > Imports
Breadcrumbs::for('imports.index', function (BreadcrumbTrail $trail) {

View File

@ -379,6 +379,33 @@
Route::put('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'update'])->name('settings.archive.update');
Route::post('settings/archive/{archiveSetting}/run', [ArchiveSettingController::class, 'run'])->name('settings.archive.run');
Route::delete('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'destroy'])->name('settings.archive.destroy');
// settings / reports settings
Route::get('settings/reports', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'index'])->name('settings.reports.index');
Route::get('settings/reports/{report}/edit', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'edit'])->name('settings.reports.edit');
Route::post('settings/reports', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'store'])->name('settings.reports.store');
Route::put('settings/reports/{report}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'update'])->name('settings.reports.update');
Route::post('settings/reports/{report}/toggle', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'toggleEnabled'])->name('settings.reports.toggle');
Route::delete('settings/reports/{report}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroy'])->name('settings.reports.destroy');
// settings / reports - entities
Route::post('settings/reports/{report}/entities', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeEntity'])->name('settings.reports.entities.store');
Route::put('settings/reports/entities/{entity}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateEntity'])->name('settings.reports.entities.update');
Route::delete('settings/reports/entities/{entity}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyEntity'])->name('settings.reports.entities.destroy');
// settings / reports - columns
Route::post('settings/reports/{report}/columns', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeColumn'])->name('settings.reports.columns.store');
Route::put('settings/reports/columns/{column}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateColumn'])->name('settings.reports.columns.update');
Route::delete('settings/reports/columns/{column}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyColumn'])->name('settings.reports.columns.destroy');
// settings / reports - filters
Route::post('settings/reports/{report}/filters', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeFilter'])->name('settings.reports.filters.store');
Route::put('settings/reports/filters/{filter}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateFilter'])->name('settings.reports.filters.update');
Route::delete('settings/reports/filters/{filter}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyFilter'])->name('settings.reports.filters.destroy');
// settings / reports - conditions
Route::post('settings/reports/{report}/conditions', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeCondition'])->name('settings.reports.conditions.store');
Route::put('settings/reports/conditions/{condition}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateCondition'])->name('settings.reports.conditions.update');
Route::delete('settings/reports/conditions/{condition}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyCondition'])->name('settings.reports.conditions.destroy');
// settings / reports - orders
Route::post('settings/reports/{report}/orders', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeOrder'])->name('settings.reports.orders.store');
Route::put('settings/reports/orders/{order}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateOrder'])->name('settings.reports.orders.update');
Route::delete('settings/reports/orders/{order}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyOrder'])->name('settings.reports.orders.destroy');
Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow');
Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index');
@ -444,7 +471,7 @@
Route::put('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'updateMapping'])->name('importTemplates.mappings.update');
Route::delete('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete');
Route::post('imports/templates{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder');
Route::post('imports/templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
Route::post('imports/templates/{template:uuid}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
// Delete an unfinished import
Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy');
// Route::put()