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(); $import->delete();
return back()->with(['ok' => true]); return back()->with('success', 'Import deleted successfully');
} }
} }

View File

@ -2,7 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Reports\ReportRegistry; use App\Models\Report;
use App\Services\ReportQueryBuilder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@ -10,15 +11,19 @@
class ReportController extends Controller class ReportController extends Controller
{ {
public function __construct(protected ReportRegistry $registry) {} public function __construct(protected ReportQueryBuilder $queryBuilder) {}
public function index(Request $request) public function index(Request $request)
{ {
$reports = collect($this->registry->all()) $reports = Report::where('enabled', true)
->orderBy('order')
->orderBy('name')
->get()
->map(fn ($r) => [ ->map(fn ($r) => [
'slug' => $r->slug(), 'slug' => $r->slug,
'name' => $r->name(), 'name' => $r->name,
'description' => $r->description(), 'description' => $r->description,
'category' => $r->category,
]) ])
->values(); ->values();
@ -29,26 +34,30 @@ public function index(Request $request)
public function show(string $slug, Request $request) public function show(string $slug, Request $request)
{ {
$report = $this->registry->findBySlug($slug); $report = Report::with(['filters', 'columns'])
abort_if(! $report, 404); ->where('slug', $slug)
$report->authorize($request); ->where('enabled', true)
->firstOrFail();
// Accept filters & pagination from query and return initial data for server-driven table // 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()]); \Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25); $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()) $rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row)) ->map(fn ($row) => $this->normalizeRow($row))
->values(); ->values();
return Inertia::render('Reports/Show', [ return Inertia::render('Reports/Show', [
'slug' => $report->slug(), 'slug' => $report->slug,
'name' => $report->name(), 'name' => $report->name,
'description' => $report->description(), 'description' => $report->description,
'inputs' => $report->inputs(), 'inputs' => $inputs,
'columns' => $report->columns(), 'columns' => $this->buildColumnsArray($report),
'rows' => $rows, 'rows' => $rows,
'meta' => [ 'meta' => [
'total' => $paginator->total(), 'total' => $paginator->total(),
@ -62,14 +71,17 @@ public function show(string $slug, Request $request)
public function data(string $slug, Request $request) public function data(string $slug, Request $request)
{ {
$report = $this->registry->findBySlug($slug); $report = Report::with(['filters', 'columns'])
abort_if(! $report, 404); ->where('slug', $slug)
$report->authorize($request); ->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); $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()) $rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row)) ->map(fn ($row) => $this->normalizeRow($row))
@ -85,20 +97,23 @@ public function data(string $slug, Request $request)
public function export(string $slug, Request $request) public function export(string $slug, Request $request)
{ {
$report = $this->registry->findBySlug($slug); $report = Report::with(['filters', 'columns'])
abort_if(! $report, 404); ->where('slug', $slug)
$report->authorize($request); ->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')); $format = strtolower((string) $request->get('format', 'csv'));
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row)); $query = $this->queryBuilder->build($report, $filters);
$columns = $report->columns(); $rows = $query->get()->map(fn ($row) => $this->normalizeRow($row));
$filename = $report->slug().'-'.now()->format('Ymd_His'); $columns = $this->buildColumnsArray($report);
$filename = $report->slug.'-'.now()->format('Ymd_His');
if ($format === 'pdf') { if ($format === 'pdf') {
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [ $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
'name' => $report->name(), 'name' => $report->name,
'columns' => $columns, 'columns' => $columns,
'rows' => $rows, 'rows' => $rows,
]); ]);
@ -299,6 +314,35 @@ protected function validateFilters(array $inputs, Request $request): array
return $request->validate($rules); 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. * 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 if this email already exists for THIS person
$existing = Email::where('person_id', $personId)
// Check for duplicates if configured ->where('value', strtolower(trim($email)))
if ($this->getOption('deduplicate', true) && $existing) { ->first();
// 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'],
];
}
// If email already exists for this person, skip
if ($existing) {
return [ return [
'action' => 'skipped', 'action' => 'skipped',
'entity' => $existing, '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 = $this->buildPayload($mapped, new Email);
$payload['person_id'] = $personId; $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\AuthServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::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 { computed, ref, watch } from "vue";
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue"; import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
import EmptyState from "@/Components/EmptyState.vue"; import EmptyState from "@/Components/EmptyState.vue";
import DataTablePaginationClient from "@/Components/DataTable/DataTablePaginationClient.vue";
import { import {
Table, Table,
TableHeader, TableHeader,
@ -10,6 +11,8 @@ import {
TableRow, TableRow,
TableCell, TableCell,
} from "@/Components/ui/table"; } from "@/Components/ui/table";
import { Button } from "../ui/button";
import { ArrowDownNarrowWide, ArrowUpWideNarrowIcon } from "lucide-vue-next";
const props = defineProps({ const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }] columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
@ -29,6 +32,7 @@ const props = defineProps({
rowKey: { type: [String, Function], default: "id" }, rowKey: { type: [String, Function], default: "id" },
showToolbar: { type: Boolean, default: true }, showToolbar: { type: Boolean, default: true },
// Pagination UX options // Pagination UX options
showPagination: { type: Boolean, default: true },
showPageStats: { type: Boolean, default: true }, showPageStats: { type: Boolean, default: true },
showGoto: { type: Boolean, default: true }, showGoto: { type: Boolean, default: true },
maxPageLinks: { type: Number, default: 5 }, // odd number preferred 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 showingFrom = computed(() => (total.value === 0 ? 0 : startIndex.value + 1));
const showingTo = computed(() => (total.value === 0 ? 0 : endIndex.value)); 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) { function setPage(p) {
emit("update:page", Math.min(Math.max(1, p), lastPage.value)); emit("update:page", Math.min(Math.max(1, p), lastPage.value));
} }
@ -196,28 +177,26 @@ function setPageSize(ps) {
</div> </div>
</div> </div>
<div <div>
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm" <Table class="border-t">
> <TableHeader>
<Table class="text-sm">
<TableHeader
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<TableRow class="border-b"> <TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class"> <TableHead v-for="col in columns" :key="col.key" :class="col.class">
<button <Button
v-if="col.sortable" v-if="col.sortable"
type="button" variant="ghost"
class="inline-flex items-center gap-1 hover:text-indigo-600" class="text-left gap-1 p-1"
@click="toggleSort(col)" @click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'" :aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
> >
<span class="uppercase">{{ col.label }}</span> <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 v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span ><ArrowUpWideNarrowIcon
> /></span>
</button> </Button>
<span v-else>{{ col.label }}</span> <span v-else>{{ col.label }}</span>
</TableHead> </TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead> <TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
@ -232,11 +211,7 @@ function setPageSize(ps) {
@click="$emit('row:click', row)" @click="$emit('row:click', row)"
class="cursor-default hover:bg-gray-50/50" class="cursor-default hover:bg-gray-50/50"
> >
<TableCell <TableCell v-for="col in columns" :key="col.key" :class="col.class">
v-for="col in columns"
:key="col.key"
:class="col.class"
>
<template v-if="$slots['cell-' + col.key]"> <template v-if="$slots['cell-' + col.key]">
<slot <slot
:name="'cell-' + col.key" :name="'cell-' + col.key"
@ -275,10 +250,7 @@ function setPageSize(ps) {
<TableRow> <TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)"> <TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty"> <slot name="empty">
<EmptyState <EmptyState :title="emptyText" size="sm" />
:title="emptyText"
size="sm"
/>
</slot> </slot>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -286,112 +258,19 @@ function setPageSize(ps) {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div class="px-2 py-4 border-t">
<nav <DataTablePaginationClient
v-if="showPagination" v-if="showPagination"
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700" :current-page="currentPage"
aria-label="Pagination" :last-page="lastPage"
> :total="total"
<div v-if="showPageStats"> :showing-from="showingFrom"
<span v-if="total > 0" :showing-to="showingTo"
>Prikazano: {{ showingFrom }}{{ showingTo }} od {{ total }}</span :show-page-stats="showPageStats"
> :show-goto="showGoto"
<span v-else>Ni zadetkov</span> :max-page-links="maxPageLinks"
</div> @update:page="setPage"
<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>
</div> </div>
</nav>
</div>
</template> </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) { function selectItem(selectedValue) {
const newValue = selectedValue === props.modelValue ? "" : selectedValue; const newValue = selectedValue === props.modelValue ? "" : selectedValue;
emit("update:modelValue", newValue); emit("update:modelValue", newValue);
console.log(selectedValue);
open.value = false; open.value = false;
} }
</script> </script>
@ -83,7 +84,11 @@ function selectItem(selectedValue) {
v-for="item in items" v-for="item in items"
:key="item.value" :key="item.value"
:value="item.value" :value="item.value"
@select="selectItem" @select="
(ev) => {
selectItem(ev.detail.value);
}
"
> >
{{ item.label }} {{ item.label }}
<CheckIcon <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() { async function applyTemplateToImport() {
if (!importId.value || !form.value.import_template_id) return; 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 { try {
if (templateApplied.value) { if (templateApplied.value) {
const ok = window.confirm( const ok = window.confirm(
@ -762,7 +770,7 @@ async function applyTemplateToImport() {
} }
await axios.post( await axios.post(
route("importTemplates.apply", { route("importTemplates.apply", {
template: form.value.import_template_id, template: template.uuid,
import: importId.value, import: importId.value,
}), }),
{}, {},

View File

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

View File

@ -1,27 +1,105 @@
<script setup> <script setup>
import { Link } from '@inertiajs/vue3' import { Link } from "@inertiajs/vue3";
import AppLayout from '@/Layouts/AppLayout.vue' 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({ defineProps({
reports: { type: Array, required: true }, 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> </script>
<template> <template>
<AppLayout title="Poročila"> <AppLayout title="Poročila">
<template #header /> <template #header />
<div class="pt-8"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-6"> <div class="mb-8">
<h1 class="text-2xl font-semibold">Poročila</h1> <h1 class="text-3xl font-bold text-gray-900">Poročila</h1>
<p class="text-gray-600">Izberite poročilo za pregled in izvoz.</p> <p class="mt-2 text-sm text-muted-foreground">
Izberite poročilo za pregled in izvoz podatkov
</p>
</div> </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"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<h2 class="text-lg font-medium mb-1">{{ r.name }}</h2> <Card
<p class="text-sm text-gray-600 mb-3">{{ r.description }}</p> v-for="r in reports"
<Link :href="route('reports.show', r.slug)" class="inline-flex items-center text-indigo-600 hover:underline">Odpri </Link> :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> </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> </div>
</div> </div>

View File

@ -1,9 +1,27 @@
<script setup> <script setup>
import { reactive, ref, computed, onMounted } from "vue"; import { reactive, ref, computed, onMounted } from "vue";
import { Link } from "@inertiajs/vue3"; import { Link, router, usePage } from "@inertiajs/vue3";
import axios from "axios"; import axios from "axios";
import AppLayout from "@/Layouts/AppLayout.vue"; 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({ const props = defineProps({
slug: { type: String, required: true }, 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() { function resetFilters() {
for (const i of props.inputs || []) { for (const i of props.inputs || []) {
filters[i.key] = i.default ?? null; 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) { function exportFile(fmt) {
const params = new URLSearchParams({ const params = new URLSearchParams({
format: fmt, format: fmt,
@ -88,11 +147,11 @@ onMounted(() => {
function formatNumberEU(val) { function formatNumberEU(val) {
if (typeof val !== "number") return String(val ?? ""); if (typeof val !== "number") return String(val ?? "");
// Use 0 decimals for integers, 2 for decimals // 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 const opts = hasFraction
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }; : { maximumFractionDigits: 0 };
return new Intl.NumberFormat("sl-SI", opts).format(num); return new Intl.NumberFormat("sl-SI", opts).format(val);
} }
function pad2(n) { function pad2(n) {
@ -184,40 +243,99 @@ function formatCell(value, key) {
<template> <template>
<AppLayout :title="name"> <AppLayout :title="name">
<template #header /> <template #header />
<div class="pt-6"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-4 flex items-start justify-between gap-4"> <!-- Header Card -->
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div> <div>
<h1 class="text-2xl font-semibold">{{ name }}</h1> <CardTitle>{{ name }}</CardTitle>
<p v-if="description" class="text-gray-600">{{ description }}</p> <CardDescription v-if="description">{{ description }}</CardDescription>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <Button variant="outline" size="sm" @click="exportFile('csv')">
type="button" <Download class="mr-2 h-4 w-4" />
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="exportFile('csv')"
>
CSV CSV
</button> </Button>
<button <Button variant="outline" size="sm" @click="exportFile('pdf')">
type="button" <Download class="mr-2 h-4 w-4" />
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="exportFile('pdf')"
>
PDF PDF
</button> </Button>
<button <Button variant="outline" size="sm" @click="exportFile('xlsx')">
type="button" <Download class="mr-2 h-4 w-4" />
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="exportFile('xlsx')"
>
Excel Excel
</button> </Button>
</div> </div>
</div> </div>
<div class="mb-4 grid gap-3 md:grid-cols-4"> </CardHeader>
<div v-for="inp in inputs" :key="inp.key" class="flex flex-col"> </Card>
<label class="text-sm text-gray-700 mb-1">{{ inp.label || inp.key }}</label>
<!-- 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 #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>
<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'"> <template v-if="inp.type === 'date'">
<DatePicker <DatePicker
v-model="filters[inp.key]" v-model="filters[inp.key]"
@ -226,86 +344,92 @@ function formatCell(value, key) {
/> />
</template> </template>
<template v-else-if="inp.type === 'integer'"> <template v-else-if="inp.type === 'integer'">
<input <Input
type="number"
v-model.number="filters[inp.key]" v-model.number="filters[inp.key]"
class="border rounded px-2 py-1" type="number"
placeholder="Vnesi število"
/> />
</template> </template>
<template v-else-if="inp.type === 'select:user'"> <template v-else-if="inp.type === 'select:user'">
<select v-model.number="filters[inp.key]" class="border rounded px-2 py-1"> <AppCombobox
<option :value="null"> brez </option> v-model="filters[inp.key]"
<option v-for="u in userOptions" :key="u.id" :value="u.id"> :items="
{{ u.name }} userOptions.map((u) => ({ value: u.id, label: u.name }))
</option> "
</select> placeholder="Brez"
<div v-if="userLoading" class="text-xs text-gray-500 mt-1">Nalagam</div> 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>
<template v-else-if="inp.type === 'select:client'"> <template v-else-if="inp.type === 'select:client'">
<select <AppCombobox
v-model="filters[inp.key]" v-model="filters[inp.key]"
class="border rounded px-2 py-1" :items="
@change=" clientOptions.map((c) => ({ value: c.id, label: c.name }))
(e) => {
console.log('Select changed:', e.target.value, 'filters:', filters);
}
" "
> placeholder="Brez"
<option :value="null"> brez </option> search-placeholder="Išči stranko..."
<option v-for="c in clientOptions" :key="c.id" :value="c.id"> empty-text="Ni strank"
{{ c.name }} :disabled="clientLoading"
</option> button-class="w-full"
</select> />
<div v-if="clientLoading" class="text-xs text-gray-500 mt-1">Nalagam</div> <div v-if="clientLoading" class="text-xs text-muted-foreground">
Nalagam
</div>
</template> </template>
<template v-else> <template v-else>
<input <Input
type="text"
v-model="filters[inp.key]" v-model="filters[inp.key]"
class="border rounded px-2 py-1" type="text"
placeholder="Vnesi vrednost"
/> />
</template> </template>
</div> </div>
<div class="flex items-end gap-2"> <div class="flex justify-end gap-2 pt-2 border-t">
<button <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" type="button"
variant="outline"
size="sm"
:disabled="appliedFilterCount === 0"
@click="resetFilters" @click="resetFilters"
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
> >
Ponastavi Počisti
</button> </Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div> </div>
</div> </div>
</div>
<!-- data table component --> </AppPopover>
<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"
>
<template #cell="{ value, column }">
{{ formatCell(value, column.key) }}
</template> </template>
</DataTableServer> <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>
</div> </div>
</AppLayout> </AppLayout>

View File

@ -1,7 +1,14 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.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 { Alert, AlertDescription, AlertTitle } from "@/Components/ui/alert";
import { useForm, router } from "@inertiajs/vue3"; import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue"; 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({ const props = defineProps({
settings: Object, settings: Object,
@ -29,7 +36,6 @@ const newForm = useForm({
// Editing state & form // Editing state & form
const editingSetting = ref(null); const editingSetting = ref(null);
// Conditions temporarily inactive in backend; keep placeholder for future restore
const originalEntityMeta = ref({ columns: ["id"] }); const originalEntityMeta = ref({ columns: ["id"] });
const editForm = useForm({ const editForm = useForm({
name: "", name: "",
@ -47,14 +53,6 @@ const editForm = useForm({
options: { batch_size: 200 }, 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() { function submitCreate() {
if (!newForm.focus) { if (!newForm.focus) {
alert("Select a focus entity."); alert("Select a focus entity.");
@ -68,11 +66,11 @@ function submitCreate() {
{ {
table: newForm.focus, table: newForm.focus,
related: newForm.related, related: newForm.related,
// conditions omitted while inactive
columns: ["id"], columns: ["id"],
}, },
]; ];
newForm.post(route("settings.archive.store"), { newForm.post(route("settings.archive.store"), {
preserveScroll: true,
onSuccess: () => { onSuccess: () => {
newForm.focus = ""; newForm.focus = "";
newForm.related = []; newForm.related = [];
@ -80,21 +78,26 @@ function submitCreate() {
newForm.action_id = null; newForm.action_id = null;
newForm.decision_id = null; newForm.decision_id = null;
newForm.segment_id = null; newForm.segment_id = null;
selectedEntity.value = null; newForm.reset();
}, },
}); });
} }
function toggleEnabled(setting) { function toggleEnabled(setting) {
router.put(route("settings.archive.update", setting.id), { router.put(
route("settings.archive.update", setting.id),
{
...setting, ...setting,
enabled: !setting.enabled, enabled: !setting.enabled,
}); },
{
preserveScroll: true,
}
);
} }
function startEdit(setting) { function startEdit(setting) {
editingSetting.value = setting; editingSetting.value = setting;
// Populate editForm
editForm.name = setting.name || ""; editForm.name = setting.name || "";
editForm.description = setting.description || ""; editForm.description = setting.description || "";
editForm.enabled = setting.enabled; editForm.enabled = setting.enabled;
@ -104,7 +107,7 @@ function startEdit(setting) {
editForm.action_id = setting.action_id ?? null; editForm.action_id = setting.action_id ?? null;
editForm.decision_id = setting.decision_id ?? null; editForm.decision_id = setting.decision_id ?? null;
editForm.segment_id = setting.segment_id ?? null; editForm.segment_id = setting.segment_id ?? null;
// Entities (first only)
const first = Array.isArray(setting.entities) ? setting.entities[0] : null; const first = Array.isArray(setting.entities) ? setting.entities[0] : null;
if (first) { if (first) {
editForm.focus = first.table || ""; editForm.focus = first.table || "";
@ -112,20 +115,16 @@ function startEdit(setting) {
originalEntityMeta.value = { originalEntityMeta.value = {
columns: first.columns || ["id"], columns: first.columns || ["id"],
}; };
const found = props.archiveEntities.find((e) => e.focus === editForm.focus);
selectedEntity.value = found || null;
} else { } else {
editForm.focus = ""; editForm.focus = "";
editForm.related = []; editForm.related = [];
originalEntityMeta.value = { columns: ["id"] }; originalEntityMeta.value = { columns: ["id"] };
// If reactivate is checked it implies soft semantics; keep soft true (UI might show both)
} }
} }
function cancelEdit() { function cancelEdit() {
editingSetting.value = null; editingSetting.value = null;
editForm.reset(); editForm.reset();
selectedEntity.value = null;
} }
function submitUpdate() { function submitUpdate() {
@ -142,11 +141,11 @@ function submitUpdate() {
{ {
table: editForm.focus, table: editForm.focus,
related: editForm.related, related: editForm.related,
// conditions omitted while inactive
columns: originalEntityMeta.value.columns || ["id"], columns: originalEntityMeta.value.columns || ["id"],
}, },
]; ];
editForm.put(route("settings.archive.update", editingSetting.value.id), { editForm.put(route("settings.archive.update", editingSetting.value.id), {
preserveScroll: true,
onSuccess: () => { onSuccess: () => {
cancelEdit(); cancelEdit();
}, },
@ -155,427 +154,93 @@ function submitUpdate() {
function remove(setting) { function remove(setting) {
if (!confirm("Delete archive rule?")) return; 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> </script>
<template> <template>
<AppLayout title="Archive Settings"> <AppLayout title="Archive Settings">
<template #header> <template #header />
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Archive Settings</h2> <div class="pt-12">
</template> <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">
<div class="py-6 max-w-6xl mx-auto px-4"> <AlertTitle class="text-sm font-medium text-amber-800">
<div class="mb-6 border-l-4 border-amber-500 bg-amber-50 text-amber-800 px-4 py-3 rounded"> Archive rule conditions are temporarily inactive
<p class="text-sm font-medium">Archive rule conditions are temporarily inactive.</p> </AlertTitle>
<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> <AlertDescription class="text-xs text-amber-800 space-y-2 mt-2">
<p class="text-xs mt-1 font-medium">The "Run Now" action is currently disabled.</p> <p>
<div class="mt-3 text-xs bg-white/60 rounded p-3 border border-amber-200"> 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="font-semibold mb-1 text-amber-900">Chain Path Help</p>
<p class="mb-1">Supported chained related tables (dot notation):</p> <p class="mb-1">Supported chained related tables (dot notation):</p>
<ul class="list-disc ml-4 space-y-0.5"> <ul class="list-disc ml-4 space-y-0.5">
<li v-for="cp in chainPatterns" :key="cp"> <li v-for="cp in chainPatterns" :key="cp">
<code class="px-1 bg-amber-100 rounded">{{ cp }}</code> <code class="px-1 bg-amber-100 rounded text-xs">{{ cp }}</code>
</li> </li>
</ul> </ul>
<p class="mt-1 italic">Only these chains are processed; others are ignored.</p> <p class="mt-1 italic">Only these chains are processed; others are ignored.</p>
</div> </div>
</div> </AlertDescription>
</Alert>
<div class="grid gap-6 md:grid-cols-3"> <div class="grid gap-6 md:grid-cols-3">
<div class="md:col-span-2 space-y-4"> <div class="md:col-span-2 space-y-4">
<div <AppCard
v-for="s in settings.data" title=""
:key="s.id" padding="none"
class="border rounded-lg p-4 bg-white shadow-sm" class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
> >
<div class="flex items-start justify-between gap-4"> <template #header>
<div class="min-w-0"> <div class="flex items-center gap-2">
<h3 class="font-medium text-gray-900 flex items-center gap-2"> <Archive :size="18" />
<span class="truncate">{{ s.name || "Untitled Rule #" + s.id }}</span> <CardTitle class="uppercase">Archive Rules</CardTitle>
<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>
<div class="flex flex-col items-end gap-2 shrink-0"> </template>
<button <div class="p-4 space-y-4">
@click="startEdit(s)" <ArchiveRuleCard
class="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-800 hover:bg-gray-300" v-for="rule in settings.data"
> :key="rule.id"
Edit :rule="rule"
</button> @edit="startEdit"
<!-- Run Now removed --> @toggle-enabled="toggleEnabled"
<button @delete="remove"
@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 v-if="newForm.errors.name" class="text-red-600 text-xs mt-1">
{{ newForm.errors.name }}
</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>
</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 <div
class="text-xs text-gray-500" v-if="!settings.data.length"
v-if="editingSetting.strategy === 'manual'" class="text-sm text-muted-foreground text-center py-8"
> >
Manual strategy: this rule will only run when triggered manually. No archive rules defined yet.
</div> </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>
<div> </AppCard>
<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>
<div> <div>
<label class="block text-xs font-medium text-gray-600" <CreateRuleForm
>Decision (optional)</label v-if="!editingSetting"
> :form="newForm"
<select :archive-entities="archiveEntities"
v-model="editForm.decision_id" :actions="actions"
:disabled="!editForm.action_id" :segments="segments"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm" @submit="submitCreate"
>
<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"> <EditRuleForm
{{ editForm.errors.name }} v-else
</div> :form="editForm"
</div> :setting="editingSetting"
<div> :archive-entities="archiveEntities"
<label class="block text-xs font-medium text-gray-600" :actions="actions"
>Focus Entity</label :segments="segments"
> @submit="submitUpdate"
<select @cancel="cancelEdit"
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> </div>
</div> </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> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from "@/Layouts/AppLayout.vue";
import SectionTitle from '@/Components/SectionTitle.vue' import AppCard from "@/Components/app/ui/card/AppCard.vue";
import PrimaryButton from '@/Components/PrimaryButton.vue' import CardTitle from "@/Components/ui/card/CardTitle.vue";
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue' import { Button } from "@/Components/ui/button";
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue' import {
import ConfirmationModal from '@/Components/ConfirmationModal.vue' Dialog,
import InputLabel from '@/Components/InputLabel.vue' DialogContent,
import InputError from '@/Components/InputError.vue' DialogHeader,
import { useForm, router } from '@inertiajs/vue3' DialogTitle,
import { ref } from 'vue' 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({ const props = defineProps({
configs: Array, configs: Array,
types: Array, types: Array,
segments: Array, segments: Array,
}) });
// create modal // create modal
const showCreate = ref(false) const showCreate = ref(false);
const openCreate = () => { showCreate.value = true } const openCreate = () => {
const closeCreate = () => { showCreate.value = false; createForm.reset() } showCreate.value = true;
const createForm = useForm({ contract_type_id: null, segment_id: null, is_initial: false }) };
const closeCreate = () => {
showCreate.value = false;
createForm.reset();
};
const createForm = useForm({
contract_type_id: null,
segment_id: null,
is_initial: false,
});
const submitCreate = () => { const submitCreate = () => {
createForm.post(route('settings.contractConfigs.store'), { createForm.post(route("settings.contractConfigs.store"), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => closeCreate(), onSuccess: () => closeCreate(),
}) });
} };
// inline edit // inline edit
const editing = ref(null) const showEdit = ref(false);
const editForm = useForm({ segment_id: null, is_initial: false, active: true }) const editing = ref(null);
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 editFormIsInitial = ref(false);
const closeEdit = () => { editing.value = null } 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 = () => { const submitEdit = () => {
if (!editing.value) return if (!editing.value) return;
editForm.put(route('settings.contractConfigs.update', editing.value.id), { editForm.is_initial = editFormIsInitial.value;
editForm.active = editFormActive.value;
editForm.put(route("settings.contractConfigs.update", editing.value.id), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => closeEdit(), onSuccess: () => closeEdit(),
}) });
} };
// delete confirmation // delete confirmation
const showDelete = ref(false) const showDelete = ref(false);
const toDelete = ref(null) const toDelete = ref(null);
const confirmDelete = (row) => { toDelete.value = row; showDelete.value = true } const confirmDelete = (row) => {
const cancelDelete = () => { toDelete.value = null; showDelete.value = false } toDelete.value = row;
showDelete.value = true;
};
const cancelDelete = () => {
toDelete.value = null;
showDelete.value = false;
};
const destroyConfig = () => { const destroyConfig = () => {
if (!toDelete.value) return if (!toDelete.value) return;
router.delete(route('settings.contractConfigs.destroy', toDelete.value.id), { router.delete(route("settings.contractConfigs.destroy", toDelete.value.id), {
preserveScroll: true, preserveScroll: true,
onFinish: () => cancelDelete(), 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> </script>
<template> <template>
@ -60,116 +143,195 @@ const destroyConfig = () => {
<template #header /> <template #header />
<div class="pt-12"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <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"> <AppCard
<div class="p-4 flex items-center justify-between"> title=""
<SectionTitle> padding="none"
<template #title>Contract configurations</template> class="p-0! gap-0"
</SectionTitle> header-class="py-3! px-4 gap-0 text-muted-foreground"
<PrimaryButton @click="openCreate">+ New</PrimaryButton> 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> </div>
<div class="px-4 pb-4"> <Button @click="openCreate">+ New</Button>
<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>
</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> </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>
</div> </div>
<!-- create modal --> <!-- create modal -->
<CreateDialog <Dialog v-model:open="showCreate">
:show="showCreate" <DialogContent class="max-w-xl">
title="New Contract Configuration" <DialogHeader>
confirm-text="Create" <DialogTitle>New Contract Configuration</DialogTitle>
:processing="createForm.processing" </DialogHeader>
:disabled="!createForm.contract_type_id || !createForm.segment_id"
@close="closeCreate"
@confirm="submitCreate"
>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<InputLabel for="type">Contract Type</InputLabel> <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"> <Select v-model="createForm.contract_type_id">
<option :value="null" disabled>-- select type --</option> <SelectTrigger id="type" class="w-full">
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.name }}</option> <SelectValue placeholder="-- select type --" />
</select> </SelectTrigger>
<InputError :message="createForm.errors.contract_type_id" /> <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>
<div> <div>
<InputLabel for="segment">Segment</InputLabel> <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"> <Select v-model="createForm.segment_id">
<option :value="null" disabled>-- select segment --</option> <SelectTrigger id="segment" class="w-full">
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option> <SelectValue placeholder="-- select segment --" />
</select> </SelectTrigger>
<InputError :message="createForm.errors.segment_id" /> <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"> <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"> <Checkbox id="is_initial" v-model="createForm.is_initial" />
<label for="is_initial" class="text-sm text-gray-700">Mark as initial</label> <InputLabel for="is_initial" class="text-sm font-normal cursor-pointer"
>Mark as initial</InputLabel
>
</div> </div>
</div> </div>
</div> </div>
</CreateDialog> <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 --> <!-- simple inline edit dialog -->
<UpdateDialog <Dialog v-model:open="showEdit">
:show="!!editing" <DialogContent class="max-w-xl">
title="Edit Configuration" <DialogHeader>
confirm-text="Save" <DialogTitle>Edit Configuration</DialogTitle>
:processing="editForm.processing" </DialogHeader>
:disabled="!editForm.segment_id"
@close="closeEdit"
@confirm="submitEdit"
>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<InputLabel>Segment</InputLabel> <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"> <Select v-model="editForm.segment_id">
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option> <SelectTrigger class="w-full">
</select> <SelectValue placeholder="-- select segment --" />
<InputError :message="editForm.errors.segment_id" /> </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>
<div class="flex items-center gap-2"> <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"> <Checkbox id="is_initial_edit" v-model="editFormIsInitial" />
<label for="is_initial_edit" class="text-sm text-gray-700">Initial</label> <InputLabel for="is_initial_edit" class="text-sm font-normal cursor-pointer"
>Initial</InputLabel
>
</div> </div>
<div class="flex items-center gap-2"> <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"> <Checkbox id="active" v-model="editFormActive" />
<label for="active" class="text-sm text-gray-700">Active</label> <InputLabel for="active" class="text-sm font-normal cursor-pointer"
>Active</InputLabel
>
</div> </div>
</div> </div>
</UpdateDialog> <DialogFooter>
<Button variant="outline" @click="closeEdit">Cancel</Button>
<Button
@click="submitEdit"
:disabled="editForm.processing || !editForm.segment_id"
>Save</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout> </AppLayout>
<ConfirmationModal :show="showDelete" @close="cancelDelete"> <AlertDialog v-model:open="showDelete">
<template #title> <AlertDialogContent>
Delete configuration <AlertDialogHeader>
</template> <AlertDialogTitle>Delete configuration</AlertDialogTitle>
<template #content> </AlertDialogHeader>
Are you sure you want to delete configuration for type "{{ toDelete?.type?.name }}"? <div class="text-sm text-muted-foreground">
</template> Are you sure you want to delete configuration for type "{{
<template #footer> toDelete?.type?.name
<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> </div>
</template> <AlertDialogFooter>
</ConfirmationModal> <Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="destroyConfig">Delete</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template> </template>

View File

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

View File

@ -1,84 +1,105 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.vue"; 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 { 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> </script>
<template> <template>
<AppLayout title="Settings"> <AppLayout title="Settings">
<template #header></template> <template #header />
<div class="pt-12"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="mb-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <h1 class="text-3xl font-bold text-gray-900">Settings</h1>
<h3 class="text-lg font-semibold mb-2">Segments</h3> <p class="mt-2 text-sm text-muted-foreground">
<p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p> Manage your application configuration and preferences
<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> </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>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Workflow</h3> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<p class="text-sm text-gray-600 mb-4"> <Card
Configure actions and decisions relationships. v-for="card in settingsCards"
</p> :key="card.route"
<Link class="hover:shadow-lg transition-shadow"
: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 <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> </div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <CardTitle class="text-lg">{{ card.title }}</CardTitle>
<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>
<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> </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> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm } from '@inertiajs/vue3' import { useForm } from "@inertiajs/vue3";
import { computed, watch } from 'vue' 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({ const props = defineProps({
setting: Object, setting: Object,
decisions: Array, decisions: Array,
actions: Array, actions: Array,
}) });
const form = useForm({ 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, create_activity_on_payment: !!props.setting?.create_activity_on_payment,
default_action_id: props.setting?.default_action_id ?? null, default_action_id: props.setting?.default_action_id ?? null,
default_decision_id: props.setting?.default_decision_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 filteredDecisions = computed(() => {
const actionId = form.default_action_id const actionId = form.default_action_id;
if (!actionId) return [] if (!actionId) return [];
const action = props.actions?.find(a => a.id === actionId) const action = props.actions?.find((a) => a.id === actionId);
if (!action || !action.decision_ids) return [] if (!action || !action.decision_ids) return [];
const ids = new Set(action.decision_ids) const ids = new Set(action.decision_ids);
return (props.decisions || []).filter(d => ids.has(d.id)) return (props.decisions || []).filter((d) => ids.has(d.id));
}) });
watch(() => form.default_action_id, (newVal) => { watch(
() => form.default_action_id,
(newVal) => {
if (!newVal) { if (!newVal) {
form.default_decision_id = null form.default_decision_id = null;
} else { } else {
// If current decision not in filtered list, clear it // If current decision not in filtered list, clear it
const ids = new Set((filteredDecisions.value || []).map(d => d.id)) const ids = new Set((filteredDecisions.value || []).map((d) => d.id));
if (!ids.has(form.default_decision_id)) { if (!ids.has(form.default_decision_id)) {
form.default_decision_id = null form.default_decision_id = null;
} }
} }
}) }
);
const submit = () => { const submit = () => {
form.put(route('settings.payment.update'), { form.put(route("settings.payment.update"), {
preserveScroll: true, preserveScroll: true,
}) });
} };
</script> </script>
<template> <template>
<AppLayout title="Nastavitve plačil"> <AppLayout title="Nastavitve plačil">
<template #header></template> <template #header></template>
<div class="max-w-3xl mx-auto p-6"> <div class="max-w-3xl mx-auto p-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"> <AppCard
<h1 class="text-xl font-semibold text-gray-900">Nastavitve plačil</h1> title=""
padding="none"
<div class="mt-6 grid gap-6"> 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> <div>
<label class="block text-sm text-gray-700 mb-1">Privzeta valuta</label> <InputLabel for="currency">Privzeta valuta</InputLabel>
<input type="text" maxlength="3" v-model="form.default_currency" class="w-40 rounded border-gray-300" /> <Input
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">{{ form.errors.default_currency }}</div> 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> <div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2"> <Checkbox id="create-activity" v-model="form.create_activity_on_payment" />
<input type="checkbox" v-model="form.create_activity_on_payment" /> <InputLabel for="create-activity" class="text-sm font-normal cursor-pointer">
<span class="text-sm text-gray-700">Ustvari aktivnost ob dodanem plačilu</span> Ustvari aktivnost ob dodanem plačilu
</label> </InputLabel>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="block text-sm text-gray-700 mb-1">Privzeto dejanje</label> <InputLabel for="default-action">Privzeto dejanje</InputLabel>
<select v-model="form.default_action_id" class="w-full rounded border-gray-300"> <Select v-model="form.default_action_id">
<option :value="null"> Brez </option> <SelectTrigger id="default-action">
<option v-for="a in actions" :key="a.id" :value="a.id">{{ a.name }}</option> <SelectValue placeholder="— Brez —" />
</select> </SelectTrigger>
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">{{ form.errors.default_action_id }}</div> <SelectContent>
</div> <SelectItem :value="null"> Brez </SelectItem>
<div> <SelectItem v-for="a in actions" :key="a.id" :value="a.id">{{
<label class="block text-sm text-gray-700 mb-1">Privzeta odločitev</label> a.name
<select v-model="form.default_decision_id" class="w-full rounded border-gray-300" :disabled="!form.default_action_id"> }}</SelectItem>
<option :value="null"> Najprej izberite dejanje </option> </SelectContent>
<option v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{ d.name }}</option> </Select>
</select> <div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">
<div v-if="form.errors.default_decision_id" class="text-sm text-red-600 mt-1">{{ form.errors.default_decision_id }}</div> {{ form.errors.default_action_id }}
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm text-gray-700 mb-1">Predloga opombe aktivnosti</label> <InputLabel for="default-decision">Privzeta odločitev</InputLabel>
<input type="text" v-model="form.activity_note_template" class="w-full rounded border-gray-300" /> <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> <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
v-if="form.errors.activity_note_template"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.activity_note_template }}
</div>
</div> </div>
<div class="flex justify-end gap-2"> <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 variant="outline" @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 @click="submit" :disabled="form.processing">Shrani</Button>
</div>
</div> </div>
</div> </div>
</AppCard>
</div> </div>
</AppLayout> </AppLayout>
</template> </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 AppLayout from "@/Layouts/AppLayout.vue";
import { useForm, router } from "@inertiajs/vue3"; import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue"; import { ref } from "vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue"; import CardTitle from "@/Components/ui/card/CardTitle.vue";
import PrimaryButton from "@/Components/PrimaryButton.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 InputLabel from "@/Components/InputLabel.vue";
import InputError from "@/Components/InputError.vue"; import InputError from "@/Components/InputError.vue";
import TextInput from "@/Components/TextInput.vue";
const props = defineProps({ const props = defineProps({
segments: Array, segments: Array,
@ -79,144 +84,150 @@ const update = () => {
<template #header></template> <template #header></template>
<div class="pt-12"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <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"> <AppCard
<div class="flex items-center justify-between mb-4"> title=""
<h2 class="text-xl font-semibold">Segments</h2> padding="none"
<PrimaryButton @click="openCreate">+ New</PrimaryButton> 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> </div>
<CreateDialog <!-- Create Dialog -->
:show="showCreate" <Dialog v-model:open="showCreate">
title="New Segment" <DialogContent>
confirm-text="Create" <DialogHeader>
:processing="createForm.processing" <DialogTitle>New Segment</DialogTitle>
@close="closeCreate" </DialogHeader>
@confirm="store" <div class="space-y-4">
>
<form @submit.prevent="store" class="space-y-4">
<div> <div>
<InputLabel for="nameCreate" value="Name" /> <InputLabel for="nameCreate">Name</InputLabel>
<TextInput <Input
id="nameCreate" id="nameCreate"
v-model="createForm.name" v-model="createForm.name"
type="text" type="text"
class="mt-1 block w-full"
/> />
<InputError :message="createForm.errors.name" class="mt-1" /> <InputError :message="createForm.errors.name" class="mt-1" />
</div> </div>
<div> <div>
<InputLabel for="descCreate" value="Description" /> <InputLabel for="descCreate">Description</InputLabel>
<TextInput <Input
id="descCreate" id="descCreate"
v-model="createForm.description" v-model="createForm.description"
type="text" type="text"
class="mt-1 block w-full"
/> />
<InputError :message="createForm.errors.description" class="mt-1" /> <InputError :message="createForm.errors.description" class="mt-1" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input id="activeCreate" type="checkbox" v-model="createForm.active" /> <Checkbox id="activeCreate" v-model="createForm.active" />
<label for="activeCreate">Active</label> <InputLabel for="activeCreate" class="text-sm font-normal cursor-pointer">
Active
</InputLabel>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <Checkbox id="excludeCreate" v-model="createForm.exclude" />
id="excludeCreate" <InputLabel for="excludeCreate" class="text-sm font-normal cursor-pointer">
type="checkbox" Exclude
v-model="createForm.exclude" </InputLabel>
/>
<label for="excludeCreate">Exclude</label>
</div> </div>
</form> </div>
</CreateDialog> <DialogFooter>
<Button variant="outline" @click="closeCreate">Cancel</Button>
<Button @click="store" :disabled="createForm.processing">Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UpdateDialog <!-- Edit Dialog -->
:show="showEdit" <Dialog v-model:open="showEdit">
title="Edit Segment" <DialogContent>
confirm-text="Save" <DialogHeader>
:processing="editForm.processing" <DialogTitle>Edit Segment</DialogTitle>
@close="closeEdit" </DialogHeader>
@confirm="update" <div class="space-y-4">
>
<form @submit.prevent="update" class="space-y-4">
<div> <div>
<InputLabel for="nameEdit" value="Name" /> <InputLabel for="nameEdit">Name</InputLabel>
<TextInput <Input
id="nameEdit" id="nameEdit"
v-model="editForm.name" v-model="editForm.name"
type="text" type="text"
class="mt-1 block w-full"
/> />
<InputError :message="editForm.errors.name" class="mt-1" /> <InputError :message="editForm.errors.name" class="mt-1" />
</div> </div>
<div> <div>
<InputLabel for="descEdit" value="Description" /> <InputLabel for="descEdit">Description</InputLabel>
<TextInput <Input
id="descEdit" id="descEdit"
v-model="editForm.description" v-model="editForm.description"
type="text" type="text"
class="mt-1 block w-full"
/> />
<InputError :message="editForm.errors.description" class="mt-1" /> <InputError :message="editForm.errors.description" class="mt-1" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input id="activeEdit" type="checkbox" v-model="editForm.active" /> <Checkbox id="activeEdit" v-model="editForm.active" />
<label for="activeEdit">Active</label> <InputLabel for="activeEdit" class="text-sm font-normal cursor-pointer">
Active
</InputLabel>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input id="excludeEdit" type="checkbox" v-model="editForm.exclude" /> <Checkbox id="excludeEdit" v-model="editForm.exclude" />
<label for="excludeEdit">Exclude</label> <InputLabel for="excludeEdit" class="text-sm font-normal cursor-pointer">
Exclude
</InputLabel>
</div> </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>
</div> </div>
</AppLayout> </AppLayout>

View File

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

View File

@ -24,6 +24,18 @@
$trail->push('Contract Configs', route('settings.contractConfigs.index')); $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 // Dashboard
Breadcrumbs::for('dashboard', function (BreadcrumbTrail $trail) { Breadcrumbs::for('dashboard', function (BreadcrumbTrail $trail) {
$trail->push('Nadzorna plošča', route('dashboard')); $trail->push('Nadzorna plošča', route('dashboard'));
@ -109,6 +121,21 @@
$trail->push('Terenska dela', route('fieldjobs.index')); $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 // Dashboard > Imports
Breadcrumbs::for('imports.index', function (BreadcrumbTrail $trail) { 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::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::post('settings/archive/{archiveSetting}/run', [ArchiveSettingController::class, 'run'])->name('settings.archive.run');
Route::delete('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'destroy'])->name('settings.archive.destroy'); 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/workflow', [WorkflowController::class, 'index'])->name('settings.workflow');
Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index'); 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::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::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: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 // Delete an unfinished import
Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy'); Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy');
// Route::put() // Route::put()