New report system and views
This commit is contained in:
parent
9fc5b54b8a
commit
703b52ff59
398
REPORTS_BACKEND_REWORK_PLAN.md
Normal file
398
REPORTS_BACKEND_REWORK_PLAN.md
Normal 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
|
||||
528
REPORTS_FRONTEND_REWORK_PLAN.md
Normal file
528
REPORTS_FRONTEND_REWORK_PLAN.md
Normal 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.
|
||||
|
|
@ -827,6 +827,6 @@ public function destroy(Request $request, Import $import)
|
|||
|
||||
$import->delete();
|
||||
|
||||
return back()->with(['ok' => true]);
|
||||
return back()->with('success', 'Import deleted successfully');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Reports\ReportRegistry;
|
||||
use App\Models\Report;
|
||||
use App\Services\ReportQueryBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
|
|
@ -10,15 +11,19 @@
|
|||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct(protected ReportRegistry $registry) {}
|
||||
public function __construct(protected ReportQueryBuilder $queryBuilder) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$reports = collect($this->registry->all())
|
||||
$reports = Report::where('enabled', true)
|
||||
->orderBy('order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($r) => [
|
||||
'slug' => $r->slug(),
|
||||
'name' => $r->name(),
|
||||
'description' => $r->description(),
|
||||
'slug' => $r->slug,
|
||||
'name' => $r->name,
|
||||
'description' => $r->description,
|
||||
'category' => $r->category,
|
||||
])
|
||||
->values();
|
||||
|
||||
|
|
@ -29,26 +34,30 @@ public function index(Request $request)
|
|||
|
||||
public function show(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
$report = Report::with(['filters', 'columns'])
|
||||
->where('slug', $slug)
|
||||
->where('enabled', true)
|
||||
->firstOrFail();
|
||||
|
||||
// Accept filters & pagination from query and return initial data for server-driven table
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
$paginator = $report->paginate($filters, $perPage);
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Show', [
|
||||
'slug' => $report->slug(),
|
||||
'name' => $report->name(),
|
||||
'description' => $report->description(),
|
||||
'inputs' => $report->inputs(),
|
||||
'columns' => $report->columns(),
|
||||
'slug' => $report->slug,
|
||||
'name' => $report->name,
|
||||
'description' => $report->description,
|
||||
'inputs' => $inputs,
|
||||
'columns' => $this->buildColumnsArray($report),
|
||||
'rows' => $rows,
|
||||
'meta' => [
|
||||
'total' => $paginator->total(),
|
||||
|
|
@ -62,14 +71,17 @@ public function show(string $slug, Request $request)
|
|||
|
||||
public function data(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
$report = Report::with(['filters', 'columns'])
|
||||
->where('slug', $slug)
|
||||
->where('enabled', true)
|
||||
->firstOrFail();
|
||||
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
|
||||
$paginator = $report->paginate($filters, $perPage);
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
|
|
@ -85,20 +97,23 @@ public function data(string $slug, Request $request)
|
|||
|
||||
public function export(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
$report = Report::with(['filters', 'columns'])
|
||||
->where('slug', $slug)
|
||||
->where('enabled', true)
|
||||
->firstOrFail();
|
||||
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
$format = strtolower((string) $request->get('format', 'csv'));
|
||||
|
||||
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row));
|
||||
$columns = $report->columns();
|
||||
$filename = $report->slug().'-'.now()->format('Ymd_His');
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$rows = $query->get()->map(fn ($row) => $this->normalizeRow($row));
|
||||
$columns = $this->buildColumnsArray($report);
|
||||
$filename = $report->slug.'-'.now()->format('Ymd_His');
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
|
||||
'name' => $report->name(),
|
||||
'name' => $report->name,
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
|
|
@ -299,6 +314,35 @@ protected function validateFilters(array $inputs, Request $request): array
|
|||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inputs array from report filters.
|
||||
*/
|
||||
protected function buildInputsArray(Report $report): array
|
||||
{
|
||||
return $report->filters->map(fn($filter) => [
|
||||
'key' => $filter->key,
|
||||
'type' => $filter->type,
|
||||
'label' => $filter->label,
|
||||
'nullable' => $filter->nullable,
|
||||
'default' => $filter->default_value,
|
||||
'options' => $filter->options,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build columns array from report columns.
|
||||
*/
|
||||
protected function buildColumnsArray(Report $report): array
|
||||
{
|
||||
return $report->columns
|
||||
->where('visible', true)
|
||||
->map(fn($col) => [
|
||||
'key' => $col->key,
|
||||
'label' => $col->label,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure derived export/display fields exist on row objects.
|
||||
*/
|
||||
|
|
|
|||
293
app/Http/Controllers/Settings/ReportSettingsController.php
Normal file
293
app/Http/Controllers/Settings/ReportSettingsController.php
Normal 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
48
app/Models/Report.php
Normal 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');
|
||||
}
|
||||
}
|
||||
33
app/Models/ReportColumn.php
Normal file
33
app/Models/ReportColumn.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Models/ReportCondition.php
Normal file
33
app/Models/ReportCondition.php
Normal 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);
|
||||
}
|
||||
}
|
||||
29
app/Models/ReportEntity.php
Normal file
29
app/Models/ReportEntity.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
app/Models/ReportFilter.php
Normal file
32
app/Models/ReportFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Models/ReportOrder.php
Normal file
25
app/Models/ReportOrder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -73,30 +73,21 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
|||
];
|
||||
}
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
// Check for duplicates if configured
|
||||
if ($this->getOption('deduplicate', true) && $existing) {
|
||||
// Update person_id if different
|
||||
if ($existing->person_id !== $personId) {
|
||||
$existing->person_id = $personId;
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => ['person_id'],
|
||||
];
|
||||
}
|
||||
// Check if this email already exists for THIS person
|
||||
$existing = Email::where('person_id', $personId)
|
||||
->where('value', strtolower(trim($email)))
|
||||
->first();
|
||||
|
||||
// If email already exists for this person, skip
|
||||
if ($existing) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Email already exists',
|
||||
'message' => 'Email already exists for this person',
|
||||
];
|
||||
}
|
||||
|
||||
// Create new email
|
||||
// Create new email for this person
|
||||
$payload = $this->buildPayload($mapped, new Email);
|
||||
$payload['person_id'] = $personId;
|
||||
|
||||
|
|
|
|||
248
app/Services/ReportQueryBuilder.php
Normal file
248
app/Services/ReportQueryBuilder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,5 +5,4 @@
|
|||
App\Providers\AuthServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\JetstreamServiceProvider::class,
|
||||
App\Providers\ReportServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
147
clean-duplicates.php
Normal file
147
clean-duplicates.php
Normal 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";
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
786
database/seeders/ReportsSeeder.php
Normal file
786
database/seeders/ReportsSeeder.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { computed, ref, watch } from "vue";
|
||||
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import DataTablePaginationClient from "@/Components/DataTable/DataTablePaginationClient.vue";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
|
|
@ -10,6 +11,8 @@ import {
|
|||
TableRow,
|
||||
TableCell,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "../ui/button";
|
||||
import { ArrowDownNarrowWide, ArrowUpWideNarrowIcon } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
|
||||
|
|
@ -29,6 +32,7 @@ const props = defineProps({
|
|||
rowKey: { type: [String, Function], default: "id" },
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
// Pagination UX options
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showPageStats: { type: Boolean, default: true },
|
||||
showGoto: { type: Boolean, default: true },
|
||||
maxPageLinks: { type: Number, default: 5 }, // odd number preferred
|
||||
|
|
@ -139,29 +143,6 @@ const pageRows = computed(() => sortedRows.value.slice(startIndex.value, endInde
|
|||
const showingFrom = computed(() => (total.value === 0 ? 0 : startIndex.value + 1));
|
||||
const showingTo = computed(() => (total.value === 0 ? 0 : endIndex.value));
|
||||
|
||||
const gotoInput = ref("");
|
||||
function goToPageInput() {
|
||||
const raw = String(gotoInput.value || "").trim();
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return;
|
||||
const target = Math.max(1, Math.min(lastPage.value, Math.floor(n)));
|
||||
if (target !== currentPage.value) setPage(target);
|
||||
gotoInput.value = "";
|
||||
}
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = [];
|
||||
const count = lastPage.value;
|
||||
if (count <= 1) return [1];
|
||||
const windowSize = Math.max(3, props.maxPageLinks);
|
||||
const half = Math.floor(windowSize / 2);
|
||||
let start = Math.max(1, currentPage.value - half);
|
||||
let end = Math.min(count, start + windowSize - 1);
|
||||
start = Math.max(1, Math.min(start, end - windowSize + 1));
|
||||
for (let p = start; p <= end; p++) pages.push(p);
|
||||
return pages;
|
||||
});
|
||||
|
||||
function setPage(p) {
|
||||
emit("update:page", Math.min(Math.max(1, p), lastPage.value));
|
||||
}
|
||||
|
|
@ -196,28 +177,26 @@ function setPageSize(ps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
>
|
||||
<Table class="text-sm">
|
||||
<TableHeader
|
||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<Table class="border-t">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b">
|
||||
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<button
|
||||
<Button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 hover:text-indigo-600"
|
||||
variant="ghost"
|
||||
class="text-left gap-1 p-1"
|
||||
@click="toggleSort(col)"
|
||||
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
||||
>
|
||||
<span class="uppercase">{{ col.label }}</span>
|
||||
<span v-if="sort?.key === col.key && sort.direction === 'asc'">▲</span>
|
||||
<span v-if="sort?.key === col.key && sort.direction === 'asc'"
|
||||
><ArrowDownNarrowWide
|
||||
/></span>
|
||||
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
|
||||
>▼</span
|
||||
>
|
||||
</button>
|
||||
><ArrowUpWideNarrowIcon
|
||||
/></span>
|
||||
</Button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</TableHead>
|
||||
<TableHead v-if="$slots.actions" class="w-px"> </TableHead>
|
||||
|
|
@ -232,11 +211,7 @@ function setPageSize(ps) {
|
|||
@click="$emit('row:click', row)"
|
||||
class="cursor-default hover:bg-gray-50/50"
|
||||
>
|
||||
<TableCell
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="col.class"
|
||||
>
|
||||
<TableCell v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<template v-if="$slots['cell-' + col.key]">
|
||||
<slot
|
||||
:name="'cell-' + col.key"
|
||||
|
|
@ -275,10 +250,7 @@ function setPageSize(ps) {
|
|||
<TableRow>
|
||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<slot name="empty">
|
||||
<EmptyState
|
||||
:title="emptyText"
|
||||
size="sm"
|
||||
/>
|
||||
<EmptyState :title="emptyText" size="sm" />
|
||||
</slot>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -286,112 +258,19 @@ function setPageSize(ps) {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="showPagination"
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div v-if="showPageStats">
|
||||
<span v-if="total > 0"
|
||||
>Prikazano: {{ showingFrom }}–{{ showingTo }} od {{ total }}</span
|
||||
>
|
||||
<span v-else>Ni zadetkov</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- First -->
|
||||
<button
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="setPage(1)"
|
||||
aria-label="Prva stran"
|
||||
>
|
||||
««
|
||||
</button>
|
||||
<!-- Prev -->
|
||||
<button
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="setPage(currentPage - 1)"
|
||||
aria-label="Prejšnja stran"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
||||
<!-- Leading ellipsis / first page when window doesn't include 1 -->
|
||||
<button
|
||||
v-if="visiblePages[0] > 1"
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50"
|
||||
@click="setPage(1)"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
<span v-if="visiblePages[0] > 2" class="px-1">…</span>
|
||||
|
||||
<!-- Page numbers -->
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
class="px-3 py-1 rounded border transition-colors"
|
||||
:class="
|
||||
p === currentPage
|
||||
? 'border-indigo-600 bg-indigo-600 text-white'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
"
|
||||
:aria-current="p === currentPage ? 'page' : undefined"
|
||||
@click="setPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
<!-- Trailing ellipsis / last page when window doesn't include last -->
|
||||
<span v-if="visiblePages[visiblePages.length - 1] < lastPage - 1" class="px-1"
|
||||
>…</span
|
||||
>
|
||||
<button
|
||||
v-if="visiblePages[visiblePages.length - 1] < lastPage"
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50"
|
||||
@click="setPage(lastPage)"
|
||||
>
|
||||
{{ lastPage }}
|
||||
</button>
|
||||
|
||||
<!-- Next -->
|
||||
<button
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(currentPage + 1)"
|
||||
aria-label="Naslednja stran"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
<!-- Last -->
|
||||
<button
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(lastPage)"
|
||||
aria-label="Zadnja stran"
|
||||
>
|
||||
»»
|
||||
</button>
|
||||
|
||||
<!-- Goto page -->
|
||||
<div v-if="showGoto" class="ms-2 flex items-center gap-1">
|
||||
<input
|
||||
v-model="gotoInput"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="lastPage"
|
||||
inputmode="numeric"
|
||||
class="w-16 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 text-sm"
|
||||
:placeholder="String(currentPage)"
|
||||
aria-label="Pojdi na stran"
|
||||
@keyup.enter="goToPageInput"
|
||||
@blur="goToPageInput"
|
||||
/>
|
||||
<span class="text-gray-500">/ {{ lastPage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="px-2 py-4 border-t">
|
||||
<DataTablePaginationClient
|
||||
v-if="showPagination"
|
||||
:current-page="currentPage"
|
||||
:last-page="lastPage"
|
||||
:total="total"
|
||||
:showing-from="showingFrom"
|
||||
:showing-to="showingTo"
|
||||
:show-page-stats="showPageStats"
|
||||
:show-goto="showGoto"
|
||||
:max-page-links="maxPageLinks"
|
||||
@update:page="setPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
205
resources/js/Components/DataTable/DataTablePaginationClient.vue
Normal file
205
resources/js/Components/DataTable/DataTablePaginationClient.vue
Normal 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>
|
||||
50
resources/js/Components/app/ui/AppCheckboxArray.vue
Normal file
50
resources/js/Components/app/ui/AppCheckboxArray.vue
Normal 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>
|
||||
|
|
@ -55,6 +55,7 @@ const selectedItem = computed(() =>
|
|||
function selectItem(selectedValue) {
|
||||
const newValue = selectedValue === props.modelValue ? "" : selectedValue;
|
||||
emit("update:modelValue", newValue);
|
||||
console.log(selectedValue);
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -83,7 +84,11 @@ function selectItem(selectedValue) {
|
|||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
@select="selectItem"
|
||||
@select="
|
||||
(ev) => {
|
||||
selectItem(ev.detail.value);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ item.label }}
|
||||
<CheckIcon
|
||||
|
|
|
|||
38
resources/js/Components/ui/alert/Alert.vue
Normal file
38
resources/js/Components/ui/alert/Alert.vue
Normal 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>
|
||||
16
resources/js/Components/ui/alert/AlertDescription.vue
Normal file
16
resources/js/Components/ui/alert/AlertDescription.vue
Normal 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>
|
||||
16
resources/js/Components/ui/alert/AlertTitle.vue
Normal file
16
resources/js/Components/ui/alert/AlertTitle.vue
Normal 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>
|
||||
3
resources/js/Components/ui/alert/index.js
Normal file
3
resources/js/Components/ui/alert/index.js
Normal 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";
|
||||
50
resources/js/Components/ui/checkbox/CheckboxArray.vue
Normal file
50
resources/js/Components/ui/checkbox/CheckboxArray.vue
Normal 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>
|
||||
|
|
@ -751,6 +751,14 @@ async function fetchColumns() {
|
|||
|
||||
async function applyTemplateToImport() {
|
||||
if (!importId.value || !form.value.import_template_id) return;
|
||||
|
||||
// Find the selected template to get its UUID
|
||||
const template = (props.templates || []).find((t) => t.id === form.value.import_template_id);
|
||||
if (!template?.uuid) {
|
||||
console.error('Template UUID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (templateApplied.value) {
|
||||
const ok = window.confirm(
|
||||
|
|
@ -762,7 +770,7 @@ async function applyTemplateToImport() {
|
|||
}
|
||||
await axios.post(
|
||||
route("importTemplates.apply", {
|
||||
template: form.value.import_template_id,
|
||||
template: template.uuid,
|
||||
import: importId.value,
|
||||
}),
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@
|
|||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import {
|
||||
|
|
@ -225,41 +234,45 @@ function formatDateTimeNoSeconds(value) {
|
|||
</TableActions>
|
||||
</template>
|
||||
</DataTable>
|
||||
<ConfirmationModal
|
||||
:show="confirming"
|
||||
@close="
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog
|
||||
:open="confirming"
|
||||
@update:open="
|
||||
(val) => {
|
||||
if (!val) {
|
||||
confirming = false;
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Potrditev brisanja</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz
|
||||
shrambe, če je še prisotna.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<p v-if="errorMsg" class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
@click="
|
||||
confirming = false;
|
||||
deletingId = null;
|
||||
"
|
||||
>
|
||||
<template #title>Potrditev brisanja</template>
|
||||
<template #content>
|
||||
<p class="text-sm">
|
||||
Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz
|
||||
shrambe, če je še prisotna.
|
||||
</p>
|
||||
<p v-if="errorMsg" class="text-sm text-red-600 mt-2">{{ errorMsg }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm border rounded me-2"
|
||||
@click="
|
||||
confirming = false;
|
||||
deletingId = null;
|
||||
"
|
||||
>
|
||||
Prekliči
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded bg-red-600 text-white"
|
||||
@click="performDelete"
|
||||
>
|
||||
Izbriši
|
||||
</button>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
Prekliči
|
||||
</AlertDialogCancel>
|
||||
<Button @click="performDelete" class="bg-destructive hover:bg-destructive/90">
|
||||
Izbriši
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,105 @@
|
|||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { BarChart3, FileText, TrendingUp, Activity } from "lucide-vue-next";
|
||||
|
||||
defineProps({
|
||||
reports: { type: Array, required: true },
|
||||
})
|
||||
});
|
||||
|
||||
// Icon mapping by category
|
||||
const reportIcons = {
|
||||
contracts: FileText,
|
||||
field: TrendingUp,
|
||||
activities: Activity,
|
||||
default: BarChart3,
|
||||
};
|
||||
|
||||
function getReportIcon(category) {
|
||||
return reportIcons[category] || reportIcons.default;
|
||||
}
|
||||
|
||||
// Generate report URL with default date filters for reports that need them
|
||||
function getReportUrl(slug) {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Format dates as YYYY-MM-DD
|
||||
const formatDate = (date) => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Report-specific default parameters
|
||||
const reportDefaults = {
|
||||
'field-jobs-completed': {
|
||||
from: formatDate(startOfMonth),
|
||||
to: formatDate(now)
|
||||
},
|
||||
'decisions-counts': {
|
||||
from: formatDate(startOfMonth),
|
||||
to: formatDate(now)
|
||||
},
|
||||
'activities-per-period': {
|
||||
from: formatDate(startOfMonth),
|
||||
to: formatDate(now),
|
||||
period: 'day'
|
||||
}
|
||||
};
|
||||
|
||||
const params = reportDefaults[slug];
|
||||
|
||||
if (params) {
|
||||
return route('reports.show', { slug, ...params });
|
||||
}
|
||||
|
||||
return route('reports.show', slug);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Poročila">
|
||||
<template #header />
|
||||
<div class="pt-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">Poročila</h1>
|
||||
<p class="text-gray-600">Izberite poročilo za pregled in izvoz.</p>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Poročila</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Izberite poročilo za pregled in izvoz podatkov
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="r in reports" :key="r.slug" class="border rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition">
|
||||
<h2 class="text-lg font-medium mb-1">{{ r.name }}</h2>
|
||||
<p class="text-sm text-gray-600 mb-3">{{ r.description }}</p>
|
||||
<Link :href="route('reports.show', r.slug)" class="inline-flex items-center text-indigo-600 hover:underline">Odpri →</Link>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card
|
||||
v-for="r in reports"
|
||||
:key="r.slug"
|
||||
class="hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="getReportIcon(r.category)"
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
<CardTitle>{{ r.name }}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{{ r.description }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link :href="getReportUrl(r.slug)">
|
||||
<Button variant="ghost" size="sm" class="w-full justify-start">
|
||||
Odpri →
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
<script setup>
|
||||
import { reactive, ref, computed, onMounted } from "vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import DatePicker from "@/Components/DatePicker.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import { Download, Filter, RotateCcw } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
slug: { type: String, required: true },
|
||||
|
|
@ -27,12 +45,53 @@ const filters = reactive(
|
|||
)
|
||||
);
|
||||
|
||||
const filterPopoverOpen = ref(false);
|
||||
|
||||
const appliedFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
for (const inp of props.inputs || []) {
|
||||
const value = filters[inp.key];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
function resetFilters() {
|
||||
for (const i of props.inputs || []) {
|
||||
filters[i.key] = i.default ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
filterPopoverOpen.value = false;
|
||||
router.get(
|
||||
route("reports.show", props.slug),
|
||||
{ ...filters, per_page: props.meta?.per_page || 25, page: 1 },
|
||||
{ preserveState: false, replace: false }
|
||||
);
|
||||
}
|
||||
|
||||
// If no query params were provided and we have inputs with defaults, apply filters automatically
|
||||
// OR if there are validation errors for required filters, redirect with defaults
|
||||
onMounted(() => {
|
||||
const page = usePage();
|
||||
const errors = page.props.errors || {};
|
||||
const hasQueryParams = Object.keys(props.query || {}).length > 0;
|
||||
const hasRequiredFilters = (props.inputs || []).some(i => i.required);
|
||||
|
||||
// Check if we have validation errors for required filter fields
|
||||
const hasRequiredFilterErrors = (props.inputs || []).some(
|
||||
i => i.required && errors[i.key]
|
||||
);
|
||||
|
||||
if ((!hasQueryParams && hasRequiredFilters) || hasRequiredFilterErrors) {
|
||||
// Apply filters with defaults on first load to satisfy required fields
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
|
||||
function exportFile(fmt) {
|
||||
const params = new URLSearchParams({
|
||||
format: fmt,
|
||||
|
|
@ -88,11 +147,11 @@ onMounted(() => {
|
|||
function formatNumberEU(val) {
|
||||
if (typeof val !== "number") return String(val ?? "");
|
||||
// Use 0 decimals for integers, 2 for decimals
|
||||
const hasFraction = Math.abs(num % 1) > 0;
|
||||
const hasFraction = Math.abs(val % 1) > 0;
|
||||
const opts = hasFraction
|
||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||
: { maximumFractionDigits: 0 };
|
||||
return new Intl.NumberFormat("sl-SI", opts).format(num);
|
||||
return new Intl.NumberFormat("sl-SI", opts).format(val);
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
|
|
@ -184,128 +243,193 @@ function formatCell(value, key) {
|
|||
<template>
|
||||
<AppLayout :title="name">
|
||||
<template #header />
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{{ name }}</h1>
|
||||
<p v-if="description" class="text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="exportFile('csv')"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="exportFile('pdf')"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="exportFile('xlsx')"
|
||||
>
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 grid gap-3 md:grid-cols-4">
|
||||
<div v-for="inp in inputs" :key="inp.key" class="flex flex-col">
|
||||
<label class="text-sm text-gray-700 mb-1">{{ inp.label || inp.key }}</label>
|
||||
<template v-if="inp.type === 'date'">
|
||||
<DatePicker
|
||||
v-model="filters[inp.key]"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datum"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'integer'">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="filters[inp.key]"
|
||||
class="border rounded px-2 py-1"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:user'">
|
||||
<select v-model.number="filters[inp.key]" class="border rounded px-2 py-1">
|
||||
<option :value="null">— brez —</option>
|
||||
<option v-for="u in userOptions" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="userLoading" class="text-xs text-gray-500 mt-1">Nalagam…</div>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:client'">
|
||||
<select
|
||||
v-model="filters[inp.key]"
|
||||
class="border rounded px-2 py-1"
|
||||
@change="
|
||||
(e) => {
|
||||
console.log('Select changed:', e.target.value, 'filters:', filters);
|
||||
}
|
||||
"
|
||||
>
|
||||
<option :value="null">— brez —</option>
|
||||
<option v-for="c in clientOptions" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="clientLoading" class="text-xs text-gray-500 mt-1">Nalagam…</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters[inp.key]"
|
||||
class="border rounded px-2 py-1"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="
|
||||
$inertia.get(
|
||||
route('reports.show', slug),
|
||||
{ ...filters, per_page: meta?.per_page || 25, page: 1 },
|
||||
{ preserveState: false, replace: false }
|
||||
)
|
||||
"
|
||||
>
|
||||
Prikaži
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="resetFilters"
|
||||
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
|
||||
>
|
||||
Ponastavi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header Card -->
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ name }}</CardTitle>
|
||||
<CardDescription v-if="description">{{ description }}</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" @click="exportFile('csv')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="exportFile('pdf')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<!-- data table component -->
|
||||
<DataTableServer
|
||||
:columns="columns.map((c) => ({ key: c.key, label: c.label || c.key }))"
|
||||
:rows="rows"
|
||||
:meta="meta"
|
||||
route-name="reports.show"
|
||||
:route-params="{ slug: slug }"
|
||||
:query="filters"
|
||||
:show-toolbar="false"
|
||||
:only-props="['rows', 'meta', 'query']"
|
||||
:preserve-state="false"
|
||||
<!-- Results Card -->
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #cell="{ value, column }">
|
||||
{{ formatCell(value, column.key) }}
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<CardTitle>Rezultati</CardTitle>
|
||||
<CardDescription>
|
||||
Skupaj {{ meta?.total || 0 }}
|
||||
{{ meta?.total === 1 ? "rezultat" : "rezultatov" }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</template>
|
||||
</DataTableServer>
|
||||
<DataTable
|
||||
:columns="
|
||||
columns.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label || c.key,
|
||||
sortable: false,
|
||||
}))
|
||||
"
|
||||
:data="rows"
|
||||
:meta="meta"
|
||||
route-name="reports.show"
|
||||
:route-params="{ slug: slug, ...filters }"
|
||||
:page-size="meta?.per_page || 25"
|
||||
:page-size-options="[10, 25, 50, 100]"
|
||||
:show-pagination="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
empty-text="Ni rezultatov."
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<AppPopover
|
||||
v-if="inputs && inputs.length > 0"
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[400px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="appliedFilterCount > 0"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ appliedFilterCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri poročila</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite parametre za zožanje prikaza podatkov.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="inp in inputs" :key="inp.key" class="space-y-1.5">
|
||||
<InputLabel>{{ inp.label || inp.key }}</InputLabel>
|
||||
<template v-if="inp.type === 'date'">
|
||||
<DatePicker
|
||||
v-model="filters[inp.key]"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datum"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'integer'">
|
||||
<Input
|
||||
v-model.number="filters[inp.key]"
|
||||
type="number"
|
||||
placeholder="Vnesi število"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:user'">
|
||||
<AppCombobox
|
||||
v-model="filters[inp.key]"
|
||||
:items="
|
||||
userOptions.map((u) => ({ value: u.id, label: u.name }))
|
||||
"
|
||||
placeholder="Brez"
|
||||
search-placeholder="Išči uporabnika..."
|
||||
empty-text="Ni uporabnikov"
|
||||
:disabled="userLoading"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<div v-if="userLoading" class="text-xs text-muted-foreground">
|
||||
Nalagam…
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:client'">
|
||||
<AppCombobox
|
||||
v-model="filters[inp.key]"
|
||||
:items="
|
||||
clientOptions.map((c) => ({ value: c.id, label: c.name }))
|
||||
"
|
||||
placeholder="Brez"
|
||||
search-placeholder="Išči stranko..."
|
||||
empty-text="Ni strank"
|
||||
:disabled="clientLoading"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<div v-if="clientLoading" class="text-xs text-muted-foreground">
|
||||
Nalagam…
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Input
|
||||
v-model="filters[inp.key]"
|
||||
type="text"
|
||||
placeholder="Vnesi vrednost"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="appliedFilterCount === 0"
|
||||
@click="resetFilters"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyFilters">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</template>
|
||||
<template
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
#[`cell-${col.key}`]="{ row }"
|
||||
>
|
||||
{{ formatCell(row[col.key], col.key) }}
|
||||
</template>
|
||||
</DataTable>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="meta.from"
|
||||
:to="meta.to"
|
||||
:total="meta.total"
|
||||
:per-page="meta.per_page || 25"
|
||||
:last-page="meta.last_page"
|
||||
:current-page="meta.current_page"
|
||||
per-page-param="per_page"
|
||||
page-param="page"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/Components/ui/alert";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { Archive } from "lucide-vue-next";
|
||||
import ArchiveRuleCard from "./Partials/ArchiveRuleCard.vue";
|
||||
import CreateRuleForm from "./Partials/CreateRuleForm.vue";
|
||||
import EditRuleForm from "./Partials/EditRuleForm.vue";
|
||||
|
||||
const props = defineProps({
|
||||
settings: Object,
|
||||
|
|
@ -29,7 +36,6 @@ const newForm = useForm({
|
|||
|
||||
// Editing state & form
|
||||
const editingSetting = ref(null);
|
||||
// Conditions temporarily inactive in backend; keep placeholder for future restore
|
||||
const originalEntityMeta = ref({ columns: ["id"] });
|
||||
const editForm = useForm({
|
||||
name: "",
|
||||
|
|
@ -47,14 +53,6 @@ const editForm = useForm({
|
|||
options: { batch_size: 200 },
|
||||
});
|
||||
|
||||
const selectedEntity = ref(null);
|
||||
|
||||
function onFocusChange() {
|
||||
const found = props.archiveEntities.find((e) => e.focus === newForm.focus);
|
||||
selectedEntity.value = found || null;
|
||||
newForm.related = [];
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
if (!newForm.focus) {
|
||||
alert("Select a focus entity.");
|
||||
|
|
@ -68,11 +66,11 @@ function submitCreate() {
|
|||
{
|
||||
table: newForm.focus,
|
||||
related: newForm.related,
|
||||
// conditions omitted while inactive
|
||||
columns: ["id"],
|
||||
},
|
||||
];
|
||||
newForm.post(route("settings.archive.store"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
newForm.focus = "";
|
||||
newForm.related = [];
|
||||
|
|
@ -80,21 +78,26 @@ function submitCreate() {
|
|||
newForm.action_id = null;
|
||||
newForm.decision_id = null;
|
||||
newForm.segment_id = null;
|
||||
selectedEntity.value = null;
|
||||
newForm.reset();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabled(setting) {
|
||||
router.put(route("settings.archive.update", setting.id), {
|
||||
...setting,
|
||||
enabled: !setting.enabled,
|
||||
});
|
||||
router.put(
|
||||
route("settings.archive.update", setting.id),
|
||||
{
|
||||
...setting,
|
||||
enabled: !setting.enabled,
|
||||
},
|
||||
{
|
||||
preserveScroll: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function startEdit(setting) {
|
||||
editingSetting.value = setting;
|
||||
// Populate editForm
|
||||
editForm.name = setting.name || "";
|
||||
editForm.description = setting.description || "";
|
||||
editForm.enabled = setting.enabled;
|
||||
|
|
@ -104,7 +107,7 @@ function startEdit(setting) {
|
|||
editForm.action_id = setting.action_id ?? null;
|
||||
editForm.decision_id = setting.decision_id ?? null;
|
||||
editForm.segment_id = setting.segment_id ?? null;
|
||||
// Entities (first only)
|
||||
|
||||
const first = Array.isArray(setting.entities) ? setting.entities[0] : null;
|
||||
if (first) {
|
||||
editForm.focus = first.table || "";
|
||||
|
|
@ -112,20 +115,16 @@ function startEdit(setting) {
|
|||
originalEntityMeta.value = {
|
||||
columns: first.columns || ["id"],
|
||||
};
|
||||
const found = props.archiveEntities.find((e) => e.focus === editForm.focus);
|
||||
selectedEntity.value = found || null;
|
||||
} else {
|
||||
editForm.focus = "";
|
||||
editForm.related = [];
|
||||
originalEntityMeta.value = { columns: ["id"] };
|
||||
// If reactivate is checked it implies soft semantics; keep soft true (UI might show both)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingSetting.value = null;
|
||||
editForm.reset();
|
||||
selectedEntity.value = null;
|
||||
}
|
||||
|
||||
function submitUpdate() {
|
||||
|
|
@ -142,11 +141,11 @@ function submitUpdate() {
|
|||
{
|
||||
table: editForm.focus,
|
||||
related: editForm.related,
|
||||
// conditions omitted while inactive
|
||||
columns: originalEntityMeta.value.columns || ["id"],
|
||||
},
|
||||
];
|
||||
editForm.put(route("settings.archive.update", editingSetting.value.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
cancelEdit();
|
||||
},
|
||||
|
|
@ -155,427 +154,93 @@ function submitUpdate() {
|
|||
|
||||
function remove(setting) {
|
||||
if (!confirm("Delete archive rule?")) return;
|
||||
router.delete(route("settings.archive.destroy", setting.id));
|
||||
router.delete(route("settings.archive.destroy", setting.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Run Now removed (feature temporarily disabled)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Archive Settings">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Archive Settings</h2>
|
||||
</template>
|
||||
<template #header />
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Alert variant="default" class="mb-6 border-l-4 border-amber-500">
|
||||
<AlertTitle class="text-sm font-medium text-amber-800">
|
||||
Archive rule conditions are temporarily inactive
|
||||
</AlertTitle>
|
||||
<AlertDescription class="text-xs text-amber-800 space-y-2 mt-2">
|
||||
<p>
|
||||
All enabled rules apply to the focus entity and its selected related tables
|
||||
without date/other filters. Stored condition JSON is preserved for future
|
||||
reactivation.
|
||||
</p>
|
||||
<p class="font-medium">The "Run Now" action is currently disabled.</p>
|
||||
<div class="mt-3 bg-white/60 rounded p-3 border border-amber-200">
|
||||
<p class="font-semibold mb-1 text-amber-900">Chain Path Help</p>
|
||||
<p class="mb-1">Supported chained related tables (dot notation):</p>
|
||||
<ul class="list-disc ml-4 space-y-0.5">
|
||||
<li v-for="cp in chainPatterns" :key="cp">
|
||||
<code class="px-1 bg-amber-100 rounded text-xs">{{ cp }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-1 italic">Only these chains are processed; others are ignored.</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="py-6 max-w-6xl mx-auto px-4">
|
||||
<div class="mb-6 border-l-4 border-amber-500 bg-amber-50 text-amber-800 px-4 py-3 rounded">
|
||||
<p class="text-sm font-medium">Archive rule conditions are temporarily inactive.</p>
|
||||
<p class="text-xs mt-1">All enabled rules apply to the focus entity and its selected related tables without date/other filters. Stored condition JSON is preserved for future reactivation.</p>
|
||||
<p class="text-xs mt-1 font-medium">The "Run Now" action is currently disabled.</p>
|
||||
<div class="mt-3 text-xs bg-white/60 rounded p-3 border border-amber-200">
|
||||
<p class="font-semibold mb-1 text-amber-900">Chain Path Help</p>
|
||||
<p class="mb-1">Supported chained related tables (dot notation):</p>
|
||||
<ul class="list-disc ml-4 space-y-0.5">
|
||||
<li v-for="cp in chainPatterns" :key="cp">
|
||||
<code class="px-1 bg-amber-100 rounded">{{ cp }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-1 italic">Only these chains are processed; others are ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="md:col-span-2 space-y-4">
|
||||
<div
|
||||
v-for="s in settings.data"
|
||||
:key="s.id"
|
||||
class="border rounded-lg p-4 bg-white shadow-sm"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-medium text-gray-900 flex items-center gap-2">
|
||||
<span class="truncate">{{ s.name || "Untitled Rule #" + s.id }}</span>
|
||||
<span
|
||||
v-if="!s.enabled"
|
||||
class="inline-flex text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800"
|
||||
>Disabled</span
|
||||
>
|
||||
</h3>
|
||||
<p v-if="s.description" class="text-sm text-gray-600 mt-1">
|
||||
{{ s.description }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Strategy: {{ s.strategy }} • Soft: {{ s.soft ? "Yes" : "No" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2 shrink-0">
|
||||
<button
|
||||
@click="startEdit(s)"
|
||||
class="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-800 hover:bg-gray-300"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<!-- Run Now removed -->
|
||||
<button
|
||||
@click="toggleEnabled(s)"
|
||||
class="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
>
|
||||
{{ s.enabled ? "Disable" : "Enable" }}
|
||||
</button>
|
||||
<button
|
||||
@click="remove(s)"
|
||||
class="text-xs px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs bg-gray-50 border rounded p-2 overflow-x-auto">
|
||||
<pre class="whitespace-pre-wrap">{{
|
||||
JSON.stringify(s.entities, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!settings.data.length" class="text-sm text-gray-600">
|
||||
No archive rules.
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!editingSetting" class="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<h3 class="font-semibold text-gray-900 mb-2 text-sm">New Rule</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Segment (optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="newForm.segment_id"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option :value="null">-- none --</option>
|
||||
<option v-for="seg in segments" :key="seg.id" :value="seg.id">
|
||||
{{ seg.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Action (optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="newForm.action_id"
|
||||
@change="
|
||||
() => {
|
||||
newForm.decision_id = null;
|
||||
}
|
||||
"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option :value="null">-- none --</option>
|
||||
<option v-for="a in actions" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Decision (optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="newForm.decision_id"
|
||||
:disabled="!newForm.action_id"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option :value="null">-- none --</option>
|
||||
<option
|
||||
v-for="d in actions.find((a) => a.id === newForm.action_id)
|
||||
?.decisions || []"
|
||||
:key="d.id"
|
||||
:value="d.id"
|
||||
>
|
||||
{{ d.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Name</label>
|
||||
<input
|
||||
v-model="newForm.name"
|
||||
type="text"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="md:col-span-2 space-y-4">
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Archive :size="18" />
|
||||
<CardTitle class="uppercase">Archive Rules</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 space-y-4">
|
||||
<ArchiveRuleCard
|
||||
v-for="rule in settings.data"
|
||||
:key="rule.id"
|
||||
:rule="rule"
|
||||
@edit="startEdit"
|
||||
@toggle-enabled="toggleEnabled"
|
||||
@delete="remove"
|
||||
/>
|
||||
<div v-if="newForm.errors.name" class="text-red-600 text-xs mt-1">
|
||||
{{ newForm.errors.name }}
|
||||
<div
|
||||
v-if="!settings.data.length"
|
||||
class="text-sm text-muted-foreground text-center py-8"
|
||||
>
|
||||
No archive rules defined yet.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Focus Entity</label
|
||||
>
|
||||
<select
|
||||
v-model="newForm.focus"
|
||||
@change="onFocusChange"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="" disabled>-- choose --</option>
|
||||
<option v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
|
||||
{{ ae.name || ae.focus }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedEntity" class="space-y-1">
|
||||
<div class="text-xs font-medium text-gray-600">Related Tables</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="r in selectedEntity.related"
|
||||
:key="r"
|
||||
class="inline-flex items-center gap-1 text-xs bg-gray-100 px-2 py-1 rounded border"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="r"
|
||||
v-model="newForm.related"
|
||||
class="rounded"
|
||||
/>
|
||||
<span>{{ r }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Description</label>
|
||||
<textarea
|
||||
v-model="newForm.description"
|
||||
rows="2"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
></textarea>
|
||||
<div v-if="newForm.errors.description" class="text-red-600 text-xs mt-1">
|
||||
{{ newForm.errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="enabled" type="checkbox" v-model="newForm.enabled" />
|
||||
<label for="enabled" class="text-xs font-medium text-gray-700"
|
||||
>Enabled</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="soft" type="checkbox" v-model="newForm.soft" />
|
||||
<label for="soft" class="text-xs font-medium text-gray-700"
|
||||
>Soft Archive</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="reactivate" type="checkbox" v-model="newForm.reactivate" />
|
||||
<label for="reactivate" class="text-xs font-medium text-gray-700"
|
||||
>Reactivate (undo archive)</label
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Strategy</label>
|
||||
<select
|
||||
v-model="newForm.strategy"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="immediate">Immediate</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="manual">Manual (never auto-run)</option>
|
||||
</select>
|
||||
<div v-if="newForm.errors.strategy" class="text-red-600 text-xs mt-1">
|
||||
{{ newForm.errors.strategy }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="submitCreate"
|
||||
type="button"
|
||||
:disabled="newForm.processing"
|
||||
class="w-full text-sm px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<div v-if="Object.keys(newForm.errors).length" class="text-xs text-red-600">
|
||||
Please fix validation errors.
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
<div v-else class="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<h3 class="font-semibold text-gray-900 mb-2 text-sm">
|
||||
Edit Rule #{{ editingSetting.id }}
|
||||
</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div
|
||||
class="text-xs text-gray-500"
|
||||
v-if="editingSetting.strategy === 'manual'"
|
||||
>
|
||||
Manual strategy: this rule will only run when triggered manually.
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Segment (optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="editForm.segment_id"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option :value="null">-- none --</option>
|
||||
<option v-for="seg in segments" :key="seg.id" :value="seg.id">
|
||||
{{ seg.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Action (optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="editForm.action_id"
|
||||
@change="
|
||||
() => {
|
||||
editForm.decision_id = null;
|
||||
}
|
||||
"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option :value="null">-- none --</option>
|
||||
<option v-for="a in actions" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Decision (optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="editForm.decision_id"
|
||||
:disabled="!editForm.action_id"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option :value="null">-- none --</option>
|
||||
<option
|
||||
v-for="d in actions.find((a) => a.id === editForm.action_id)
|
||||
?.decisions || []"
|
||||
:key="d.id"
|
||||
:value="d.id"
|
||||
>
|
||||
{{ d.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Name</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<div v-if="editForm.errors.name" class="text-red-600 text-xs mt-1">
|
||||
{{ editForm.errors.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600"
|
||||
>Focus Entity</label
|
||||
>
|
||||
<select
|
||||
v-model="editForm.focus"
|
||||
@change="onFocusChange() /* reuse selectedEntity for preview */"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="" disabled>-- choose --</option>
|
||||
<option v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
|
||||
{{ ae.name || ae.focus }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedEntity && editForm.focus === selectedEntity.focus"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="text-xs font-medium text-gray-600">Related Tables</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="r in selectedEntity.related"
|
||||
:key="r"
|
||||
class="inline-flex items-center gap-1 text-xs bg-gray-100 px-2 py-1 rounded border"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="r"
|
||||
v-model="editForm.related"
|
||||
class="rounded"
|
||||
/>
|
||||
<span>{{ r }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="2"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
></textarea>
|
||||
<div v-if="editForm.errors.description" class="text-red-600 text-xs mt-1">
|
||||
{{ editForm.errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="edit_enabled" type="checkbox" v-model="editForm.enabled" />
|
||||
<label for="edit_enabled" class="text-xs font-medium text-gray-700"
|
||||
>Enabled</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="edit_soft" type="checkbox" v-model="editForm.soft" />
|
||||
<label for="edit_soft" class="text-xs font-medium text-gray-700"
|
||||
>Soft Archive</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="edit_reactivate" type="checkbox" v-model="editForm.reactivate" />
|
||||
<label for="edit_reactivate" class="text-xs font-medium text-gray-700"
|
||||
>Reactivate (undo archive)</label
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Strategy</label>
|
||||
<select
|
||||
v-model="editForm.strategy"
|
||||
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="immediate">Immediate</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="manual">Manual (never auto-run)</option>
|
||||
</select>
|
||||
<div v-if="editForm.errors.strategy" class="text-red-600 text-xs mt-1">
|
||||
{{ editForm.errors.strategy }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="submitUpdate"
|
||||
type="button"
|
||||
:disabled="editForm.processing"
|
||||
class="flex-1 text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
@click="cancelEdit"
|
||||
type="button"
|
||||
class="px-3 py-2 rounded text-sm bg-gray-200 hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="Object.keys(editForm.errors).length"
|
||||
class="text-xs text-red-600"
|
||||
>
|
||||
Please fix validation errors.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CreateRuleForm
|
||||
v-if="!editingSetting"
|
||||
:form="newForm"
|
||||
:archive-entities="archiveEntities"
|
||||
:actions="actions"
|
||||
:segments="segments"
|
||||
@submit="submitCreate"
|
||||
/>
|
||||
<EditRuleForm
|
||||
v-else
|
||||
:form="editForm"
|
||||
:setting="editingSetting"
|
||||
:archive-entities="archiveEntities"
|
||||
:actions="actions"
|
||||
:segments="segments"
|
||||
@submit="submitUpdate"
|
||||
@cancel="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
184
resources/js/Pages/Settings/Archive/Partials/CreateRuleForm.vue
Normal file
184
resources/js/Pages/Settings/Archive/Partials/CreateRuleForm.vue
Normal 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>
|
||||
199
resources/js/Pages/Settings/Archive/Partials/EditRuleForm.vue
Normal file
199
resources/js/Pages/Settings/Archive/Partials/EditRuleForm.vue
Normal 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>
|
||||
|
|
@ -1,58 +1,141 @@
|
|||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import SectionTitle from '@/Components/SectionTitle.vue'
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue'
|
||||
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
|
||||
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'
|
||||
import ConfirmationModal from '@/Components/ConfirmationModal.vue'
|
||||
import InputLabel from '@/Components/InputLabel.vue'
|
||||
import InputError from '@/Components/InputError.vue'
|
||||
import { useForm, router } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogFooter,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, nextTick } from "vue";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash,
|
||||
FileText,
|
||||
Badge as BadgeIcon,
|
||||
Check as CheckIcon,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
configs: Array,
|
||||
types: Array,
|
||||
segments: Array,
|
||||
})
|
||||
});
|
||||
|
||||
// create modal
|
||||
const showCreate = ref(false)
|
||||
const openCreate = () => { showCreate.value = true }
|
||||
const closeCreate = () => { showCreate.value = false; createForm.reset() }
|
||||
const createForm = useForm({ contract_type_id: null, segment_id: null, is_initial: false })
|
||||
const showCreate = ref(false);
|
||||
const openCreate = () => {
|
||||
showCreate.value = true;
|
||||
};
|
||||
const closeCreate = () => {
|
||||
showCreate.value = false;
|
||||
createForm.reset();
|
||||
};
|
||||
const createForm = useForm({
|
||||
contract_type_id: null,
|
||||
segment_id: null,
|
||||
is_initial: false,
|
||||
});
|
||||
const submitCreate = () => {
|
||||
createForm.post(route('settings.contractConfigs.store'), {
|
||||
createForm.post(route("settings.contractConfigs.store"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeCreate(),
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// inline edit
|
||||
const editing = ref(null)
|
||||
const editForm = useForm({ segment_id: null, is_initial: false, active: true })
|
||||
const openEdit = (row) => { editing.value = row; editForm.segment_id = row?.segment_id ?? row?.segment?.id; editForm.is_initial = !!row.is_initial; editForm.active = !!row.active }
|
||||
const closeEdit = () => { editing.value = null }
|
||||
const showEdit = ref(false);
|
||||
const editing = ref(null);
|
||||
const editFormIsInitial = ref(false);
|
||||
const editFormActive = ref(false);
|
||||
const editForm = useForm({ segment_id: null, is_initial: false, active: false });
|
||||
const openEdit = (row) => {
|
||||
editing.value = row;
|
||||
editForm.clearErrors();
|
||||
|
||||
// Set values
|
||||
editForm.segment_id = row?.segment_id ?? row?.segment?.id ?? null;
|
||||
editFormIsInitial.value = row.is_initial === 1 || row.is_initial === true;
|
||||
editFormActive.value = row.active === 1 || row.active === true;
|
||||
|
||||
showEdit.value = true;
|
||||
};
|
||||
const closeEdit = () => {
|
||||
showEdit.value = false;
|
||||
editing.value = null;
|
||||
editFormIsInitial.value = false;
|
||||
editFormActive.value = false;
|
||||
editForm.reset();
|
||||
editForm.clearErrors();
|
||||
};
|
||||
const submitEdit = () => {
|
||||
if (!editing.value) return
|
||||
editForm.put(route('settings.contractConfigs.update', editing.value.id), {
|
||||
if (!editing.value) return;
|
||||
editForm.is_initial = editFormIsInitial.value;
|
||||
editForm.active = editFormActive.value;
|
||||
editForm.put(route("settings.contractConfigs.update", editing.value.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeEdit(),
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// delete confirmation
|
||||
const showDelete = ref(false)
|
||||
const toDelete = ref(null)
|
||||
const confirmDelete = (row) => { toDelete.value = row; showDelete.value = true }
|
||||
const cancelDelete = () => { toDelete.value = null; showDelete.value = false }
|
||||
const showDelete = ref(false);
|
||||
const toDelete = ref(null);
|
||||
const confirmDelete = (row) => {
|
||||
toDelete.value = row;
|
||||
showDelete.value = true;
|
||||
};
|
||||
const cancelDelete = () => {
|
||||
toDelete.value = null;
|
||||
showDelete.value = false;
|
||||
};
|
||||
const destroyConfig = () => {
|
||||
if (!toDelete.value) return
|
||||
router.delete(route('settings.contractConfigs.destroy', toDelete.value.id), {
|
||||
if (!toDelete.value) return;
|
||||
router.delete(route("settings.contractConfigs.destroy", toDelete.value.id), {
|
||||
preserveScroll: true,
|
||||
onFinish: () => cancelDelete(),
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// DataTable state
|
||||
const sort = ref({ key: null, direction: null });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "ID", sortable: true, class: "w-16" },
|
||||
{ key: "type", label: "Type", sortable: false },
|
||||
{ key: "segment", label: "Segment", sortable: false },
|
||||
{ key: "active", label: "Active", sortable: false, class: "w-24" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -60,116 +143,195 @@ const destroyConfig = () => {
|
|||
<template #header />
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-indigo-400">
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>Contract configurations</template>
|
||||
</SectionTitle>
|
||||
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4">Type</th>
|
||||
<th class="py-2 pr-4">Segment</th>
|
||||
<th class="py-2 pr-4">Active</th>
|
||||
<th class="py-2 pr-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cfg in configs" :key="cfg.id" class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{{ cfg.type?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ cfg.segment?.name }} <span v-if="cfg.is_initial" class="ml-2 text-xs text-indigo-600">(initial)</span></td>
|
||||
<td class="py-2 pr-4">{{ cfg.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="py-2 pr-4 text-right">
|
||||
<button class="px-2 py-1 text-indigo-600 hover:underline" @click="openEdit(cfg)">Edit</button>
|
||||
<button class="ml-2 px-2 py-1 text-red-600 hover:underline" @click="confirmDelete(cfg)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!configs || configs.length === 0">
|
||||
<td colspan="4" class="py-6 text-center text-gray-500">No configurations.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText :size="18" />
|
||||
<CardTitle class="uppercase">Contract configurations</CardTitle>
|
||||
</div>
|
||||
<Button @click="openCreate">+ New</Button>
|
||||
</div>
|
||||
</template>
|
||||
<DataTableClient
|
||||
:columns="columns"
|
||||
:rows="configs"
|
||||
:sort="sort"
|
||||
:search="''"
|
||||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
>
|
||||
<template #cell-type="{ row }">
|
||||
{{ row.type?.name }}
|
||||
</template>
|
||||
<template #cell-segment="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
{{ row.segment?.name }}
|
||||
<CheckIcon v-if="row.is_initial" class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-active="{ row }">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="row.active ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="openEdit(row)">
|
||||
<Pencil class="w-4 h-4 mr-2" />
|
||||
Uredi
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@click="confirmDelete(row)"
|
||||
class="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash class="w-4 h-4 mr-2" />
|
||||
Izbriši
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- create modal -->
|
||||
<CreateDialog
|
||||
:show="showCreate"
|
||||
title="New Contract Configuration"
|
||||
confirm-text="Create"
|
||||
:processing="createForm.processing"
|
||||
:disabled="!createForm.contract_type_id || !createForm.segment_id"
|
||||
@close="closeCreate"
|
||||
@confirm="submitCreate"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Dialog v-model:open="showCreate">
|
||||
<DialogContent class="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Contract Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="type">Contract Type</InputLabel>
|
||||
<select id="type" v-model="createForm.contract_type_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option :value="null" disabled>-- select type --</option>
|
||||
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<InputError :message="createForm.errors.contract_type_id" />
|
||||
<Select v-model="createForm.contract_type_id">
|
||||
<SelectTrigger id="type" class="w-full">
|
||||
<SelectValue placeholder="-- select type --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="t in types" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="createForm.errors.contract_type_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="segment">Segment</InputLabel>
|
||||
<select id="segment" v-model="createForm.segment_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option :value="null" disabled>-- select segment --</option>
|
||||
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<InputError :message="createForm.errors.segment_id" />
|
||||
<Select v-model="createForm.segment_id">
|
||||
<SelectTrigger id="segment" class="w-full">
|
||||
<SelectValue placeholder="-- select segment --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="createForm.errors.segment_id" class="mt-1" />
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<input id="is_initial" type="checkbox" v-model="createForm.is_initial" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_initial" class="text-sm text-gray-700">Mark as initial</label>
|
||||
<Checkbox id="is_initial" v-model="createForm.is_initial" />
|
||||
<InputLabel for="is_initial" class="text-sm font-normal cursor-pointer"
|
||||
>Mark as initial</InputLabel
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CreateDialog>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeCreate">Cancel</Button>
|
||||
<Button
|
||||
@click="submitCreate"
|
||||
:disabled="
|
||||
createForm.processing ||
|
||||
!createForm.contract_type_id ||
|
||||
!createForm.segment_id
|
||||
"
|
||||
>Create</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- simple inline edit dialog -->
|
||||
<UpdateDialog
|
||||
:show="!!editing"
|
||||
title="Edit Configuration"
|
||||
confirm-text="Save"
|
||||
:processing="editForm.processing"
|
||||
:disabled="!editForm.segment_id"
|
||||
@close="closeEdit"
|
||||
@confirm="submitEdit"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Dialog v-model:open="showEdit">
|
||||
<DialogContent class="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel>Segment</InputLabel>
|
||||
<select v-model="editForm.segment_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<InputError :message="editForm.errors.segment_id" />
|
||||
<Select v-model="editForm.segment_id">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="-- select segment --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="editForm.errors.segment_id" class="mt-1" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="is_initial_edit" type="checkbox" v-model="editForm.is_initial" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_initial_edit" class="text-sm text-gray-700">Initial</label>
|
||||
<Checkbox id="is_initial_edit" v-model="editFormIsInitial" />
|
||||
<InputLabel for="is_initial_edit" class="text-sm font-normal cursor-pointer"
|
||||
>Initial</InputLabel
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="active" type="checkbox" v-model="editForm.active" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="active" class="text-sm text-gray-700">Active</label>
|
||||
<Checkbox id="active" v-model="editFormActive" />
|
||||
<InputLabel for="active" class="text-sm font-normal cursor-pointer"
|
||||
>Active</InputLabel
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</UpdateDialog>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeEdit">Cancel</Button>
|
||||
<Button
|
||||
@click="submitEdit"
|
||||
:disabled="editForm.processing || !editForm.segment_id"
|
||||
>Save</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
<ConfirmationModal :show="showDelete" @close="cancelDelete">
|
||||
<template #title>
|
||||
Delete configuration
|
||||
</template>
|
||||
<template #content>
|
||||
Are you sure you want to delete configuration for type "{{ toDelete?.type?.name }}"?
|
||||
</template>
|
||||
<template #footer>
|
||||
<button @click="cancelDelete" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2">Cancel</button>
|
||||
<PrimaryButton @click="destroyConfig">Delete</PrimaryButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
<AlertDialog v-model:open="showDelete">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete configuration</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete configuration for type "{{
|
||||
toDelete?.type?.name
|
||||
}}"?
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" @click="cancelDelete">Cancel</Button>
|
||||
<Button variant="destructive" @click="destroyConfig">Delete</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, useForm } from "@inertiajs/vue3";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
|
||||
import PrimaryButton from "@/Components/PrimaryButton.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/Components/ui/dialog";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import { de } from "date-fns/locale";
|
||||
import { ref, onMounted, watch, computed } from "vue";
|
||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Pencil, Settings } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
settings: Array,
|
||||
|
|
@ -25,12 +39,18 @@ const decisionOptions = ref([]);
|
|||
const actionOptions = ref([]);
|
||||
|
||||
onMounted(() => {
|
||||
segmentOptions.value = (props.segments || []).map((s) => ({ id: s.id, name: s.name }));
|
||||
decisionOptions.value = (props.decisions || []).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
segmentOptions.value = (props.segments || []).map((s) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
}));
|
||||
decisionOptions.value = (props.decisions || []).map((d) => ({
|
||||
label: d.name,
|
||||
value: d.id,
|
||||
}));
|
||||
actionOptions.value = (props.actions || []).map((a) => ({
|
||||
label: a.name,
|
||||
value: a.id,
|
||||
}));
|
||||
actionOptions.value = (props.actions || []).map((a) => ({ id: a.id, name: a.name }));
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -117,18 +137,10 @@ const update = () => {
|
|||
watch(
|
||||
() => editForm.action_id,
|
||||
(newActionId) => {
|
||||
// Clear decision fields when action changes
|
||||
/*editForm.initial_decision_id = null;
|
||||
editForm.assign_decision_id = null;
|
||||
editForm.complete_decision_id = null;
|
||||
editForm.cancel_decision_id = null;*/
|
||||
if (newActionId !== null) {
|
||||
// Optionally, you can filter decisionOptions based on the selected action here
|
||||
decisionOptions.value = (props.decisions || [])
|
||||
.filter((decision) => decision.actions?.some((a) => a.id === newActionId) ?? true)
|
||||
.map((d) => ({ id: d.id, name: d.name }));
|
||||
// For simplicity, we are not implementing that logic now
|
||||
console.log(decisionOptions.value);
|
||||
.map((d) => ({ label: d.name, value: d.id }));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -136,20 +148,32 @@ watch(
|
|||
watch(
|
||||
() => form.action_id,
|
||||
(newActionId) => {
|
||||
// Clear decision fields when action changes
|
||||
form.initial_decision_id = null;
|
||||
form.assign_decision_id = null;
|
||||
form.complete_decision_id = null;
|
||||
if (newActionId !== null) {
|
||||
// Optionally, you can filter decisionOptions based on the selected action here
|
||||
decisionOptions.value = (props.decisions || [])
|
||||
.filter((decision) => decision.actions?.some((a) => a.id === newActionId) ?? true)
|
||||
.map((d) => ({ id: d.id, name: d.name }));
|
||||
// For simplicity, we are not implementing that logic now
|
||||
console.log(decisionOptions.value);
|
||||
.map((d) => ({ label: d.name, value: d.id }));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DataTable state
|
||||
const sort = ref({ key: null, direction: null });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "ID", sortable: true, class: "w-16" },
|
||||
{ key: "segment", label: "Segment", sortable: false },
|
||||
{ key: "action", label: "Action", sortable: false },
|
||||
{ key: "initial_decision", label: "Initial Decision", sortable: false },
|
||||
{ key: "assign_decision", label: "Assign Decision", sortable: false },
|
||||
{ key: "complete_decision", label: "Complete Decision", sortable: false },
|
||||
{ key: "cancel_decision", label: "Cancel Decision", sortable: false },
|
||||
{ key: "return_segment", label: "Return Segment", sortable: false },
|
||||
{ key: "queue_segment", label: "Queue Segment", sortable: false },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -157,383 +181,299 @@ watch(
|
|||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Field Job Settings</h2>
|
||||
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
|
||||
</div>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings :size="18" />
|
||||
<CardTitle class="uppercase">Field Job Settings</CardTitle>
|
||||
</div>
|
||||
<Button @click="openCreate">+ New</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CreateDialog
|
||||
:show="showCreate"
|
||||
title="Create Field Job Setting"
|
||||
confirm-text="Create"
|
||||
:processing="form.processing"
|
||||
@close="closeCreate"
|
||||
@confirm="store"
|
||||
>
|
||||
<form @submit.prevent="store">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<InputLabel for="segment" value="Segment" />
|
||||
<multiselect
|
||||
id="segment"
|
||||
v-model="form.segment_id"
|
||||
:options="segmentOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select segment"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.segment_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="action" value="Action" />
|
||||
<multiselect
|
||||
id="action"
|
||||
v-model="form.action_id"
|
||||
:options="actionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select action"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => actionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.action_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="initialDecision" value="Initial Decision" />
|
||||
<multiselect
|
||||
id="initialDecision"
|
||||
v-model="form.initial_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select initial decision"
|
||||
:append-to-body="true"
|
||||
:disabled="!form.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="assignDecision" value="Assign Decision" />
|
||||
<multiselect
|
||||
id="assignDecision"
|
||||
v-model="form.assign_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select assign decision"
|
||||
:append-to-body="true"
|
||||
:disabled="!form.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="completeDecision" value="Complete Decision" />
|
||||
<multiselect
|
||||
id="completeDecision"
|
||||
v-model="form.complete_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select complete decision"
|
||||
:append-to-body="true"
|
||||
:disabled="!form.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.complete_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="cancelDecision" value="Cancel Decision" />
|
||||
<multiselect
|
||||
id="cancelDecision"
|
||||
v-model="form.cancel_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select cancel decision (optional)"
|
||||
:append-to-body="true"
|
||||
:disabled="!form.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="returnSegment" value="Return Segment" />
|
||||
<multiselect
|
||||
id="returnSegment"
|
||||
v-model="form.return_segment_id"
|
||||
:options="segmentOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select return segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.return_segment_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="queueSegment" value="Queue Segment" />
|
||||
<multiselect
|
||||
id="queueSegment"
|
||||
v-model="form.queue_segment_id"
|
||||
:options="segmentOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select queue segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
|
||||
</div>
|
||||
<Dialog v-model:open="showCreate">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Field Job Setting</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="segment" value="Segment" />
|
||||
<AppCombobox
|
||||
id="segment"
|
||||
v-model="form.segment_id"
|
||||
:items="segmentOptions"
|
||||
placeholder="Select segment"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.segment_id" class="mt-1" />
|
||||
</div>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
<UpdateDialog
|
||||
:show="showEdit"
|
||||
title="Edit Field Job Setting"
|
||||
confirm-text="Save"
|
||||
:processing="editForm.processing"
|
||||
@close="closeEdit"
|
||||
@confirm="update"
|
||||
>
|
||||
<form @submit.prevent="update">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<InputLabel for="edit-segment" value="Segment" />
|
||||
<multiselect
|
||||
id="edit-segment"
|
||||
v-model="editForm.segment_id"
|
||||
:options="segmentOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select segment"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="editForm.errors.segment_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-action" value="Action" />
|
||||
<multiselect
|
||||
id="edit-action"
|
||||
v-model="editForm.action_id"
|
||||
:options="actionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select action"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => actionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError :message="editForm.errors.action_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-initialDecision" value="Initial Decision" />
|
||||
<multiselect
|
||||
id="edit-initialDecision"
|
||||
v-model="editForm.initial_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select initial decision"
|
||||
:append-to-body="true"
|
||||
:disabled="!editForm.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.initial_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="edit-assignDecision" value="Assign Decision" />
|
||||
<multiselect
|
||||
id="edit-assignDecision"
|
||||
v-model="editForm.assign_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select assign decision"
|
||||
:append-to-body="true"
|
||||
:disabled="!editForm.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.assign_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-completeDecision" value="Complete Decision" />
|
||||
<multiselect
|
||||
id="edit-completeDecision"
|
||||
v-model="editForm.complete_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select complete decision"
|
||||
:disabled="!editForm.action_id"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.complete_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-cancelDecision" value="Cancel Decision" />
|
||||
<multiselect
|
||||
id="edit-cancelDecision"
|
||||
v-model="editForm.cancel_decision_id"
|
||||
:options="decisionOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select cancel decision (optional)"
|
||||
:append-to-body="true"
|
||||
:disabled="!editForm.action_id"
|
||||
:custom-label="
|
||||
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.cancel_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-returnSegment" value="Return Segment" />
|
||||
<multiselect
|
||||
id="edit-returnSegment"
|
||||
v-model="editForm.return_segment_id"
|
||||
:options="segmentOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select return segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.return_segment_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-queueSegment" value="Queue Segment" />
|
||||
<multiselect
|
||||
id="edit-queueSegment"
|
||||
v-model="editForm.queue_segment_id"
|
||||
:options="segmentOptions.map((o) => o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select queue segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="
|
||||
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.queue_segment_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="action" value="Action" />
|
||||
<AppCombobox
|
||||
id="action"
|
||||
v-model="form.action_id"
|
||||
:items="actionOptions"
|
||||
placeholder="Select action"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.action_id" class="mt-1" />
|
||||
</div>
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4">ID</th>
|
||||
<th class="py-2 pr-4">Segment</th>
|
||||
<th class="py-2 pr-4">Action</th>
|
||||
<th class="py-2 pr-4">Initial Decision</th>
|
||||
<th class="py-2 pr-4">Assign Decision</th>
|
||||
<th class="py-2 pr-4">Complete Decision</th>
|
||||
<th class="py-2 pr-4">Cancel Decision</th>
|
||||
<th class="py-2 pr-4">Return Segment</th>
|
||||
<th class="py-2 pr-4">Queue Segment</th>
|
||||
<th class="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in settings" :key="row.id" class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{{ row.id }}</td>
|
||||
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.action?.name }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{ row.initial_decision?.name || row.initialDecision?.name }}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{ row.assign_decision?.name || row.assignDecision?.name }}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{ row.complete_decision?.name || row.completeDecision?.name }}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{ row.cancel_decision?.name || row.cancelDecision?.name }}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{ row.return_segment?.name || row.returnSegment?.name }}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{ row.queue_segment?.name || row.queueSegment?.name }}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<button
|
||||
@click="openEdit(row)"
|
||||
class="px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="initialDecision" value="Initial Decision" />
|
||||
<AppCombobox
|
||||
id="initialDecision"
|
||||
v-model="form.initial_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select initial decision"
|
||||
:disabled="!form.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="assignDecision" value="Assign Decision" />
|
||||
<AppCombobox
|
||||
id="assignDecision"
|
||||
v-model="form.assign_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select assign decision"
|
||||
:disabled="!form.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="completeDecision" value="Complete Decision" />
|
||||
<AppCombobox
|
||||
id="completeDecision"
|
||||
v-model="form.complete_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select complete decision"
|
||||
:disabled="!form.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.complete_decision_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="cancelDecision" value="Cancel Decision" />
|
||||
<AppCombobox
|
||||
id="cancelDecision"
|
||||
v-model="form.cancel_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select cancel decision (optional)"
|
||||
:disabled="!form.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="returnSegment" value="Return Segment" />
|
||||
<AppCombobox
|
||||
id="returnSegment"
|
||||
v-model="form.return_segment_id"
|
||||
:items="segmentOptions"
|
||||
placeholder="Select return segment (optional)"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.return_segment_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="queueSegment" value="Queue Segment" />
|
||||
<AppCombobox
|
||||
id="queueSegment"
|
||||
v-model="form.queue_segment_id"
|
||||
:items="segmentOptions"
|
||||
placeholder="Select queue segment (optional)"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeCreate">Cancel</Button>
|
||||
<Button @click="store" :disabled="form.processing">Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog v-model:open="showEdit">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Field Job Setting</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="edit-segment" value="Segment" />
|
||||
<AppCombobox
|
||||
id="edit-segment"
|
||||
v-model="editForm.segment_id"
|
||||
:items="segmentOptions"
|
||||
placeholder="Select segment"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="editForm.errors.segment_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-action" value="Action" />
|
||||
<AppCombobox
|
||||
id="edit-action"
|
||||
v-model="editForm.action_id"
|
||||
:items="actionOptions"
|
||||
placeholder="Select action"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="editForm.errors.action_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-initialDecision" value="Initial Decision" />
|
||||
<AppCombobox
|
||||
id="edit-initialDecision"
|
||||
v-model="editForm.initial_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select initial decision"
|
||||
:disabled="!editForm.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.initial_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-assignDecision" value="Assign Decision" />
|
||||
<AppCombobox
|
||||
id="edit-assignDecision"
|
||||
v-model="editForm.assign_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select assign decision"
|
||||
:disabled="!editForm.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.assign_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-completeDecision" value="Complete Decision" />
|
||||
<AppCombobox
|
||||
id="edit-completeDecision"
|
||||
v-model="editForm.complete_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select complete decision"
|
||||
:disabled="!editForm.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.complete_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-cancelDecision" value="Cancel Decision" />
|
||||
<AppCombobox
|
||||
id="edit-cancelDecision"
|
||||
v-model="editForm.cancel_decision_id"
|
||||
:items="decisionOptions"
|
||||
placeholder="Select cancel decision (optional)"
|
||||
:disabled="!editForm.action_id"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError
|
||||
:message="editForm.errors.cancel_decision_id"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-returnSegment" value="Return Segment" />
|
||||
<AppCombobox
|
||||
id="edit-returnSegment"
|
||||
v-model="editForm.return_segment_id"
|
||||
:items="segmentOptions"
|
||||
placeholder="Select return segment (optional)"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="editForm.errors.return_segment_id" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="edit-queueSegment" value="Queue Segment" />
|
||||
<AppCombobox
|
||||
id="edit-queueSegment"
|
||||
v-model="editForm.queue_segment_id"
|
||||
:items="segmentOptions"
|
||||
placeholder="Select queue segment (optional)"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<InputError :message="editForm.errors.queue_segment_id" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeEdit">Cancel</Button>
|
||||
<Button @click="update" :disabled="editForm.processing">Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DataTableClient
|
||||
:columns="columns"
|
||||
:rows="settings"
|
||||
:sort="sort"
|
||||
:search="''"
|
||||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
>
|
||||
<template #cell-segment="{ row }">
|
||||
{{ row.segment?.name }}
|
||||
</template>
|
||||
<template #cell-action="{ row }">
|
||||
{{ row.action?.name }}
|
||||
</template>
|
||||
<template #cell-initial_decision="{ row }">
|
||||
{{ row.initial_decision?.name || row.initialDecision?.name }}
|
||||
</template>
|
||||
<template #cell-assign_decision="{ row }">
|
||||
{{ row.assign_decision?.name || row.assignDecision?.name }}
|
||||
</template>
|
||||
<template #cell-complete_decision="{ row }">
|
||||
{{ row.complete_decision?.name || row.completeDecision?.name }}
|
||||
</template>
|
||||
<template #cell-cancel_decision="{ row }">
|
||||
{{ row.cancel_decision?.name || row.cancelDecision?.name }}
|
||||
</template>
|
||||
<template #cell-return_segment="{ row }">
|
||||
{{ row.return_segment?.name || row.returnSegment?.name }}
|
||||
</template>
|
||||
<template #cell-queue_segment="{ row }">
|
||||
{{ row.queue_segment?.name || row.queueSegment?.name }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="openEdit(row)">
|
||||
<Pencil class="w-4 h-4 mr-2" />
|
||||
Uredi
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,105 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import {
|
||||
Layers,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
Briefcase,
|
||||
FileText,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
title: "Segments",
|
||||
description: "Manage segments used across the app.",
|
||||
route: "settings.segments",
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
title: "Payments",
|
||||
description: "Defaults for payments and auto-activity.",
|
||||
route: "settings.payment.edit",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: "Workflow",
|
||||
description: "Configure actions and decisions relationships.",
|
||||
route: "settings.workflow",
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
title: "Field Job Settings",
|
||||
description: "Configure segment-based field job rules.",
|
||||
route: "settings.fieldjob.index",
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: "Contract Configs",
|
||||
description: "Auto-assign initial segments for contracts by type.",
|
||||
route: "settings.contractConfigs.index",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Archive Settings",
|
||||
description: "Define rules for archiving or soft-deleting aged data.",
|
||||
route: "settings.archive.index",
|
||||
icon: Archive,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
description: "Configure database-driven reports with dynamic queries.",
|
||||
route: "settings.reports.index",
|
||||
icon: BarChart3,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Settings">
|
||||
<template #header></template>
|
||||
<template #header />
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Segments</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p>
|
||||
<Link
|
||||
:href="route('settings.segments')"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Open Segments</Link
|
||||
>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Payments</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Defaults for payments and auto-activity.
|
||||
</p>
|
||||
<Link
|
||||
:href="route('settings.payment.edit')"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Open Payment Settings</Link
|
||||
>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Workflow</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Configure actions and decisions relationships.
|
||||
</p>
|
||||
<Link
|
||||
:href="route('settings.workflow')"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Open Workflow</Link
|
||||
>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Field Job Settings</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Configure segment-based field job rules.
|
||||
</p>
|
||||
<Link
|
||||
:href="route('settings.fieldjob.index')"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Open Field Job</Link
|
||||
>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Contract Configs</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Auto-assign initial segments for contracts by type.
|
||||
</p>
|
||||
<Link
|
||||
:href="route('settings.contractConfigs.index')"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Open Contract Configs</Link
|
||||
>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Archive Settings</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Define rules for archiving or soft-deleting aged data.
|
||||
</p>
|
||||
<Link
|
||||
:href="route('settings.archive.index')"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Open Archive Settings</Link
|
||||
>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Manage your application configuration and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card
|
||||
v-for="card in settingsCards"
|
||||
:key="card.route"
|
||||
class="hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<component :is="card.icon" :size="20" />
|
||||
</div>
|
||||
<CardTitle class="text-lg">{{ card.title }}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link :href="route(card.route)">
|
||||
<Button class="w-full group">
|
||||
Open Settings
|
||||
<ArrowRight
|
||||
class="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1"
|
||||
/>
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,103 +1,168 @@
|
|||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
import { computed, watch } from 'vue'
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import { computed, watch } from "vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Wallet } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
setting: Object,
|
||||
decisions: Array,
|
||||
actions: Array,
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
default_currency: props.setting?.default_currency ?? 'EUR',
|
||||
default_currency: props.setting?.default_currency ?? "EUR",
|
||||
create_activity_on_payment: !!props.setting?.create_activity_on_payment,
|
||||
default_action_id: props.setting?.default_action_id ?? null,
|
||||
default_decision_id: props.setting?.default_decision_id ?? null,
|
||||
activity_note_template: props.setting?.activity_note_template ?? 'Prejeto plačilo: {amount} {currency}',
|
||||
})
|
||||
activity_note_template:
|
||||
props.setting?.activity_note_template ?? "Prejeto plačilo: {amount} {currency}",
|
||||
});
|
||||
|
||||
const filteredDecisions = computed(() => {
|
||||
const actionId = form.default_action_id
|
||||
if (!actionId) return []
|
||||
const action = props.actions?.find(a => a.id === actionId)
|
||||
if (!action || !action.decision_ids) return []
|
||||
const ids = new Set(action.decision_ids)
|
||||
return (props.decisions || []).filter(d => ids.has(d.id))
|
||||
})
|
||||
const actionId = form.default_action_id;
|
||||
if (!actionId) return [];
|
||||
const action = props.actions?.find((a) => a.id === actionId);
|
||||
if (!action || !action.decision_ids) return [];
|
||||
const ids = new Set(action.decision_ids);
|
||||
return (props.decisions || []).filter((d) => ids.has(d.id));
|
||||
});
|
||||
|
||||
watch(() => form.default_action_id, (newVal) => {
|
||||
if (!newVal) {
|
||||
form.default_decision_id = null
|
||||
} else {
|
||||
// If current decision not in filtered list, clear it
|
||||
const ids = new Set((filteredDecisions.value || []).map(d => d.id))
|
||||
if (!ids.has(form.default_decision_id)) {
|
||||
form.default_decision_id = null
|
||||
watch(
|
||||
() => form.default_action_id,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
form.default_decision_id = null;
|
||||
} else {
|
||||
// If current decision not in filtered list, clear it
|
||||
const ids = new Set((filteredDecisions.value || []).map((d) => d.id));
|
||||
if (!ids.has(form.default_decision_id)) {
|
||||
form.default_decision_id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
form.put(route('settings.payment.update'), {
|
||||
form.put(route("settings.payment.update"), {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Nastavitve plačil">
|
||||
<template #header></template>
|
||||
<div class="max-w-3xl mx-auto p-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Nastavitve plačil</h1>
|
||||
|
||||
<div class="mt-6 grid gap-6">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">Privzeta valuta</label>
|
||||
<input type="text" maxlength="3" v-model="form.default_currency" class="w-40 rounded border-gray-300" />
|
||||
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">{{ form.errors.default_currency }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" v-model="form.create_activity_on_payment" />
|
||||
<span class="text-sm text-gray-700">Ustvari aktivnost ob dodanem plačilu</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">Privzeto dejanje</label>
|
||||
<select v-model="form.default_action_id" class="w-full rounded border-gray-300">
|
||||
<option :value="null">— Brez —</option>
|
||||
<option v-for="a in actions" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">{{ form.errors.default_action_id }}</div>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Wallet :size="18" />
|
||||
<CardTitle class="uppercase">Nastavitve plačil</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-6 p-4 border-t">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">Privzeta odločitev</label>
|
||||
<select v-model="form.default_decision_id" class="w-full rounded border-gray-300" :disabled="!form.default_action_id">
|
||||
<option :value="null">— Najprej izberite dejanje —</option>
|
||||
<option v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.default_decision_id" class="text-sm text-red-600 mt-1">{{ form.errors.default_decision_id }}</div>
|
||||
<InputLabel for="currency">Privzeta valuta</InputLabel>
|
||||
<Input
|
||||
id="currency"
|
||||
v-model="form.default_currency"
|
||||
maxlength="3"
|
||||
class="w-40"
|
||||
/>
|
||||
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">
|
||||
{{ form.errors.default_currency }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">Predloga opombe aktivnosti</label>
|
||||
<input type="text" v-model="form.activity_note_template" class="w-full rounded border-gray-300" />
|
||||
<p class="text-xs text-gray-500 mt-1">Podprti žetoni: {amount}, {currency}</p>
|
||||
<div v-if="form.errors.activity_note_template" class="text-sm text-red-600 mt-1">{{ form.errors.activity_note_template }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="create-activity" v-model="form.create_activity_on_payment" />
|
||||
<InputLabel for="create-activity" class="text-sm font-normal cursor-pointer">
|
||||
Ustvari aktivnost ob dodanem plačilu
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<InputLabel for="default-action">Privzeto dejanje</InputLabel>
|
||||
<Select v-model="form.default_action_id">
|
||||
<SelectTrigger id="default-action">
|
||||
<SelectValue placeholder="— Brez —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Brez —</SelectItem>
|
||||
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">{{
|
||||
a.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">
|
||||
{{ form.errors.default_action_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="default-decision">Privzeta odločitev</InputLabel>
|
||||
<Select
|
||||
v-model="form.default_decision_id"
|
||||
:disabled="!form.default_action_id"
|
||||
>
|
||||
<SelectTrigger id="default-decision">
|
||||
<SelectValue placeholder="— Najprej izberite dejanje —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Najprej izberite dejanje —</SelectItem>
|
||||
<SelectItem v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{
|
||||
d.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
v-if="form.errors.default_decision_id"
|
||||
class="text-sm text-red-600 mt-1"
|
||||
>
|
||||
{{ form.errors.default_decision_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="note-template">Predloga opombe aktivnosti</InputLabel>
|
||||
<Input id="note-template" v-model="form.activity_note_template" />
|
||||
<p class="text-xs text-gray-500 mt-1">Podprti žetoni: {amount}, {currency}</p>
|
||||
<div
|
||||
v-if="form.errors.activity_note_template"
|
||||
class="text-sm text-red-600 mt-1"
|
||||
>
|
||||
{{ form.errors.activity_note_template }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="form.reset()">Ponastavi</button>
|
||||
<button type="button" class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="form.processing" @click="submit">Shrani</button>
|
||||
<Button variant="outline" @click="form.reset()">Ponastavi</Button>
|
||||
<Button @click="submit" :disabled="form.processing">Shrani</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
134
resources/js/Pages/Settings/Reports/Edit.vue
Normal file
134
resources/js/Pages/Settings/Reports/Edit.vue
Normal 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>
|
||||
359
resources/js/Pages/Settings/Reports/Index.vue
Normal file
359
resources/js/Pages/Settings/Reports/Index.vue
Normal 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>
|
||||
354
resources/js/Pages/Settings/Reports/Partials/ColumnsSection.vue
Normal file
354
resources/js/Pages/Settings/Reports/Partials/ColumnsSection.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
344
resources/js/Pages/Settings/Reports/Partials/EntitiesSection.vue
Normal file
344
resources/js/Pages/Settings/Reports/Partials/EntitiesSection.vue
Normal 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>
|
||||
344
resources/js/Pages/Settings/Reports/Partials/FiltersSection.vue
Normal file
344
resources/js/Pages/Settings/Reports/Partials/FiltersSection.vue
Normal 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>
|
||||
238
resources/js/Pages/Settings/Reports/Partials/OrdersSection.vue
Normal file
238
resources/js/Pages/Settings/Reports/Partials/OrdersSection.vue
Normal 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>
|
||||
|
|
@ -2,12 +2,17 @@
|
|||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
|
||||
import PrimaryButton from "@/Components/PrimaryButton.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { LayoutGrid } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import TextInput from "@/Components/TextInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
segments: Array,
|
||||
|
|
@ -79,144 +84,150 @@ const update = () => {
|
|||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Segments</h2>
|
||||
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<LayoutGrid :size="18" />
|
||||
<CardTitle class="uppercase">Segments</CardTitle>
|
||||
</div>
|
||||
<Button @click="openCreate">+ New</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="border-t">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Active</TableHead>
|
||||
<TableHead>Exclude</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="s in segments" :key="s.id">
|
||||
<TableCell>{{ s.id }}</TableCell>
|
||||
<TableCell>{{ s.name }}</TableCell>
|
||||
<TableCell>{{ s.description }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="s.active ? 'default' : 'secondary'">
|
||||
{{ s.active ? "Yes" : "No" }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="s.exclude ? 'default' : 'secondary'">
|
||||
{{ s.exclude ? "Yes" : "No" }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" @click="openEdit(s)">
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<CreateDialog
|
||||
:show="showCreate"
|
||||
title="New Segment"
|
||||
confirm-text="Create"
|
||||
:processing="createForm.processing"
|
||||
@close="closeCreate"
|
||||
@confirm="store"
|
||||
>
|
||||
<form @submit.prevent="store" class="space-y-4">
|
||||
<!-- Create Dialog -->
|
||||
<Dialog v-model:open="showCreate">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Segment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="nameCreate" value="Name" />
|
||||
<TextInput
|
||||
<InputLabel for="nameCreate">Name</InputLabel>
|
||||
<Input
|
||||
id="nameCreate"
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
/>
|
||||
<InputError :message="createForm.errors.name" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="descCreate" value="Description" />
|
||||
<TextInput
|
||||
<InputLabel for="descCreate">Description</InputLabel>
|
||||
<Input
|
||||
id="descCreate"
|
||||
v-model="createForm.description"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
/>
|
||||
<InputError :message="createForm.errors.description" class="mt-1" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="activeCreate" type="checkbox" v-model="createForm.active" />
|
||||
<label for="activeCreate">Active</label>
|
||||
<Checkbox id="activeCreate" v-model="createForm.active" />
|
||||
<InputLabel for="activeCreate" class="text-sm font-normal cursor-pointer">
|
||||
Active
|
||||
</InputLabel>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="excludeCreate"
|
||||
type="checkbox"
|
||||
v-model="createForm.exclude"
|
||||
/>
|
||||
<label for="excludeCreate">Exclude</label>
|
||||
<Checkbox id="excludeCreate" v-model="createForm.exclude" />
|
||||
<InputLabel for="excludeCreate" class="text-sm font-normal cursor-pointer">
|
||||
Exclude
|
||||
</InputLabel>
|
||||
</div>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeCreate">Cancel</Button>
|
||||
<Button @click="store" :disabled="createForm.processing">Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UpdateDialog
|
||||
:show="showEdit"
|
||||
title="Edit Segment"
|
||||
confirm-text="Save"
|
||||
:processing="editForm.processing"
|
||||
@close="closeEdit"
|
||||
@confirm="update"
|
||||
>
|
||||
<form @submit.prevent="update" class="space-y-4">
|
||||
<!-- Edit Dialog -->
|
||||
<Dialog v-model:open="showEdit">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Segment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="nameEdit" value="Name" />
|
||||
<TextInput
|
||||
<InputLabel for="nameEdit">Name</InputLabel>
|
||||
<Input
|
||||
id="nameEdit"
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
/>
|
||||
<InputError :message="editForm.errors.name" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="descEdit" value="Description" />
|
||||
<TextInput
|
||||
<InputLabel for="descEdit">Description</InputLabel>
|
||||
<Input
|
||||
id="descEdit"
|
||||
v-model="editForm.description"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
/>
|
||||
<InputError :message="editForm.errors.description" class="mt-1" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="activeEdit" type="checkbox" v-model="editForm.active" />
|
||||
<label for="activeEdit">Active</label>
|
||||
<Checkbox id="activeEdit" v-model="editForm.active" />
|
||||
<InputLabel for="activeEdit" class="text-sm font-normal cursor-pointer">
|
||||
Active
|
||||
</InputLabel>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="excludeEdit" type="checkbox" v-model="editForm.exclude" />
|
||||
<label for="excludeEdit">Exclude</label>
|
||||
<Checkbox id="excludeEdit" v-model="editForm.exclude" />
|
||||
<InputLabel for="excludeEdit" class="text-sm font-normal cursor-pointer">
|
||||
Exclude
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4">ID</th>
|
||||
<th class="py-2 pr-4">Name</th>
|
||||
<th class="py-2 pr-4">Description</th>
|
||||
<th class="py-2 pr-4">Active</th>
|
||||
<th class="py-2 pr-4">Exclude</th>
|
||||
<th class="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in segments" :key="s.id" class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{{ s.id }}</td>
|
||||
<td class="py-2 pr-4">{{ s.name }}</td>
|
||||
<td class="py-2 pr-4">{{ s.description }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
:class="s.active ? 'bg-green-500' : 'bg-gray-400'"
|
||||
class="inline-block w-2 h-2 rounded-full"
|
||||
></span>
|
||||
{{ s.active ? "Yes" : "No" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
:class="s.exclude ? 'bg-green-500' : 'bg-gray-400'"
|
||||
class="inline-block w-2 h-2 rounded-full"
|
||||
></span>
|
||||
{{ s.exclude ? "Yes" : "No" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<button
|
||||
class="text-indigo-600 hover:text-indigo-800"
|
||||
@click="openEdit(s)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<!-- Delete intentionally skipped as requested -->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeEdit">Cancel</Button>
|
||||
<Button @click="update" :disabled="editForm.processing">Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref } from "vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Workflow } from "lucide-vue-next";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/Components/ui/tabs";
|
||||
import ActionTable from "../Partials/ActionTable.vue";
|
||||
import DecisionTable from "../Partials/DecisionTable.vue";
|
||||
import ActionTable from "./Partials/ActionTable.vue";
|
||||
import DecisionTable from "./Partials/DecisionTable.vue";
|
||||
|
||||
const props = defineProps({
|
||||
actions: Array,
|
||||
|
|
@ -21,11 +24,23 @@ const activeTab = ref("actions");
|
|||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList class="w-full justify-start border-b rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="actions" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary">Akcije</TabsTrigger>
|
||||
<TabsTrigger value="decisions" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary">Odločitve</TabsTrigger>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Workflow :size="18" />
|
||||
<CardTitle class="uppercase">Workflow</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<Tabs v-model="activeTab" class="border-t">
|
||||
<TabsList class="border-b w-full flex flex-row justify-baseline rounded-none">
|
||||
<TabsTrigger value="actions">Akcije</TabsTrigger>
|
||||
<TabsTrigger value="decisions">Odločitve</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="actions" class="mt-0">
|
||||
<ActionTable
|
||||
|
|
@ -45,7 +60,7 @@ const activeTab = ref("actions");
|
|||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
406
resources/js/Pages/Settings/Workflow/Partials/ActionTable.vue
Normal file
406
resources/js/Pages/Settings/Workflow/Partials/ActionTable.vue
Normal 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>
|
||||
|
|
@ -1,19 +1,46 @@
|
|||
<script setup>
|
||||
// flowbite-vue table imports removed; using DataTableClient
|
||||
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
|
||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogFooter,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { computed, onMounted, ref, watch, nextTick } from "vue";
|
||||
import { router, useForm } from "@inertiajs/vue3";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import TextInput from "@/Components/TextInput.vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import PrimaryButton from "@/Components/PrimaryButton.vue";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/Components/ui/select";
|
||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
const props = defineProps({
|
||||
decisions: Array,
|
||||
|
|
@ -42,12 +69,12 @@ const sort = ref({ key: null, direction: null });
|
|||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "#", sortable: true, class: "w-16" },
|
||||
{ key: "id", label: "#", sortable: true },
|
||||
{ key: "name", label: "Ime", sortable: true },
|
||||
{ key: "color_tag", label: "Barva", sortable: false },
|
||||
{ key: "events", label: "Dogodki", sortable: false, class: "w-40" },
|
||||
{ key: "belongs", label: "Pripada akcijam", sortable: false, class: "w-40" },
|
||||
{ key: "auto_mail", label: "Auto mail", sortable: false, class: "w-46" },
|
||||
{ key: "events", label: "Dogodki", sortable: false },
|
||||
{ key: "belongs", label: "Pripada akcijam", sortable: false },
|
||||
{ key: "auto_mail", label: "Auto mail", sortable: false },
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -119,12 +146,7 @@ const openEditDrawer = (item) => {
|
|||
});
|
||||
drawerEdit.value = true;
|
||||
|
||||
item.actions.forEach((a) => {
|
||||
form.actions.push({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
});
|
||||
});
|
||||
form.actions = item.actions.map((a) => a.id);
|
||||
};
|
||||
|
||||
const closeEditDrawer = () => {
|
||||
|
|
@ -145,8 +167,8 @@ const closeCreateDrawer = () => {
|
|||
onMounted(() => {
|
||||
props.actions.forEach((a) => {
|
||||
actionOptions.value.push({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
label: a.name,
|
||||
value: a.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -217,7 +239,7 @@ function tryAdoptRaw(ev) {
|
|||
const filtered = computed(() => {
|
||||
const term = search.value?.toLowerCase() ?? "";
|
||||
const tplId = selectedTemplateId.value ? Number(selectedTemplateId.value) : null;
|
||||
const evIdSet = new Set((selectedEvents.value || []).map((e) => Number(e.id)));
|
||||
const evIdSet = new Set((selectedEvents.value || []).map((e) => Number(e)));
|
||||
return (props.decisions || []).filter((d) => {
|
||||
const matchesSearch =
|
||||
!term ||
|
||||
|
|
@ -241,7 +263,22 @@ const update = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
form.put(route("settings.decisions.update", { id: form.id }), {
|
||||
// Transform actions from array of IDs to array of objects
|
||||
const actionsPayload = form.actions
|
||||
.map(id => {
|
||||
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
|
||||
if (!action) {
|
||||
console.warn('Action not found for id:', id);
|
||||
return null;
|
||||
}
|
||||
return { id: action.id, name: action.name };
|
||||
})
|
||||
.filter(Boolean); // Remove null entries
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
actions: actionsPayload
|
||||
})).put(route("settings.decisions.update", { id: form.id }), {
|
||||
onSuccess: () => {
|
||||
closeEditDrawer();
|
||||
},
|
||||
|
|
@ -260,7 +297,22 @@ const store = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
createForm.post(route("settings.decisions.store"), {
|
||||
// Transform actions from array of IDs to array of objects
|
||||
const actionsPayload = createForm.actions
|
||||
.map(id => {
|
||||
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
|
||||
if (!action) {
|
||||
console.warn('Action not found for id:', id);
|
||||
return null;
|
||||
}
|
||||
return { id: action.id, name: action.name };
|
||||
})
|
||||
.filter(Boolean); // Remove null entries
|
||||
|
||||
createForm.transform((data) => ({
|
||||
...data,
|
||||
actions: actionsPayload
|
||||
})).post(route("settings.decisions.store"), {
|
||||
onSuccess: () => {
|
||||
closeCreateDrawer();
|
||||
},
|
||||
|
|
@ -351,68 +403,65 @@ const destroyDecision = () => {
|
|||
</script>
|
||||
<template>
|
||||
<div class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="w-full bg-gray-50 border rounded-md p-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-center">
|
||||
<!-- Search -->
|
||||
<div class="relative sm:col-span-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
|
||||
<div class="flex gap-3 items-center">
|
||||
<AppPopover align="start" side="bottom" content-class="w-96">
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm">
|
||||
<FilterIcon class="w-4 h-4 mr-2" />
|
||||
Filtri
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-3 p-1">
|
||||
<div>
|
||||
<InputLabel for="searchFilter" value="Iskanje" class="mb-1" />
|
||||
<Input
|
||||
id="searchFilter"
|
||||
v-model="search"
|
||||
placeholder="Iskanje..."
|
||||
class="w-full"
|
||||
/>
|
||||
</svg>
|
||||
<TextInput v-model="search" placeholder="Iskanje..." class="w-full pl-9 h-10" />
|
||||
</div>
|
||||
<!-- Template select -->
|
||||
<div class="sm:col-span-3">
|
||||
<select
|
||||
v-model="selectedTemplateId"
|
||||
class="block w-full h-10 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option :value="null">Vse predloge</option>
|
||||
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Events multiselect -->
|
||||
<div class="sm:col-span-4">
|
||||
<multiselect
|
||||
v-model="selectedEvents"
|
||||
:options="availableEvents"
|
||||
:multiple="true"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Filtriraj po dogodkih"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- Only auto mail -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="onlyAutoMail"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 h-4 w-4"
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="templateFilter" value="Email predloga" class="mb-1" />
|
||||
<Select v-model="selectedTemplateId">
|
||||
<SelectTrigger id="templateFilter" class="w-full">
|
||||
<SelectValue placeholder="Vse predloge" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vse predloge</SelectItem>
|
||||
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="eventsFilter" value="Dogodki" class="mb-1" />
|
||||
<AppMultiSelect
|
||||
id="eventsFilter"
|
||||
v-model="selectedEvents"
|
||||
:items="availableEvents.map((e) => ({ value: e.id, label: e.name }))"
|
||||
placeholder="Filtriraj po dogodkih"
|
||||
class="w-full"
|
||||
/>
|
||||
Samo auto mail
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="onlyAutoMailFilter" v-model="onlyAutoMail" />
|
||||
<InputLabel
|
||||
for="onlyAutoMailFilter"
|
||||
class="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Samo auto mail
|
||||
</InputLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
|
||||
<div class="shrink-0">
|
||||
<Button @click="openCreateDrawer">+ Dodaj odločitev</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<div>
|
||||
<DataTableClient
|
||||
:columns="columns"
|
||||
:rows="filtered"
|
||||
|
|
@ -421,6 +470,7 @@ const destroyDecision = () => {
|
|||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
|
|
@ -496,63 +546,61 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button class="px-2" @click="openEditDrawer(row)">
|
||||
<EditIcon size="md" css="text-gray-500" />
|
||||
</button>
|
||||
<button
|
||||
class="px-2 disabled:opacity-40"
|
||||
:disabled="(row.activities_count ?? 0) > 0"
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
<TrashBinIcon size="md" css="text-red-500" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="openEditDrawer(row)">
|
||||
<Pencil class="w-4 h-4 mr-2" />
|
||||
Uredi
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
:disabled="(row.activities_count ?? 0) > 0"
|
||||
@click="confirmDelete(row)"
|
||||
class="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash class="w-4 h-4 mr-2" />
|
||||
Izbriši
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
</div>
|
||||
<UpdateDialog
|
||||
:show="drawerEdit"
|
||||
title="Spremeni odločitev"
|
||||
confirm-text="Shrani"
|
||||
:processing="form.processing"
|
||||
:disabled="!eventsValidEdit"
|
||||
@close="closeEditDrawer"
|
||||
@confirm="update"
|
||||
>
|
||||
<form @submit.prevent="update">
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Ime" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="name"
|
||||
/>
|
||||
<Dialog v-model:open="drawerEdit">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Spremeni odločitev</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="name">Ime</InputLabel>
|
||||
<Input id="name" v-model="form.name" type="text" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<input
|
||||
id="autoMailEdit"
|
||||
type="checkbox"
|
||||
v-model="form.auto_mail"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="autoMailEdit" class="text-sm">Samodejna pošta (auto mail)</label>
|
||||
<Checkbox id="autoMailEdit" v-model="form.auto_mail" />
|
||||
<InputLabel for="autoMailEdit" class="text-sm font-normal cursor-pointer">
|
||||
Samodejna pošta (auto mail)
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4 mt-2">
|
||||
<InputLabel for="emailTemplateEdit" value="Email predloga" />
|
||||
<select
|
||||
id="emailTemplateEdit"
|
||||
v-model="form.email_template_id"
|
||||
:disabled="!form.auto_mail"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option :value="null">— Brez —</option>
|
||||
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="form.email_template_id" :disabled="!form.auto_mail">
|
||||
<SelectTrigger id="emailTemplateEdit" class="w-full">
|
||||
<SelectValue placeholder="— Brez —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Brez —</SelectItem>
|
||||
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="form.email_template_id" class="text-xs text-gray-500 mt-1">
|
||||
<span
|
||||
v-if="
|
||||
|
|
@ -574,16 +622,11 @@ const destroyDecision = () => {
|
|||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="actionsSelect" value="Akcije" />
|
||||
<multiselect
|
||||
<AppMultiSelect
|
||||
id="actionsSelect"
|
||||
v-model="form.actions"
|
||||
:options="actionOptions"
|
||||
:multiple="true"
|
||||
track-by="id"
|
||||
:taggable="true"
|
||||
:items="actionOptions"
|
||||
placeholder="Dodaj akcijo"
|
||||
:append-to-body="true"
|
||||
label="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -595,43 +638,44 @@ const destroyDecision = () => {
|
|||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<InputLabel :for="`event-${idx}`" value="Dogodek" />
|
||||
<select
|
||||
:id="`event-${idx}`"
|
||||
v-model.number="ev.id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
@change="onEventChange(ev)"
|
||||
>
|
||||
<option :value="null">— Izberi —</option>
|
||||
<option v-for="opt in availableEvents" :key="opt.id" :value="opt.id">
|
||||
{{ opt.name || opt.key || `#${opt.id}` }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="ev.id" @update:model-value="onEventChange(ev)">
|
||||
<SelectTrigger :id="`event-${idx}`" class="w-full">
|
||||
<SelectValue placeholder="— Izberi —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Izberi —</SelectItem>
|
||||
<SelectItem
|
||||
v-for="opt in availableEvents"
|
||||
:key="opt.id"
|
||||
:value="opt.id"
|
||||
>
|
||||
{{ opt.name || opt.key || `#${opt.id}` }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<InputLabel :for="`order-${idx}`" value="Vrstni red" />
|
||||
<TextInput
|
||||
<InputLabel :for="`order-${idx}`">Vrstni red</InputLabel>
|
||||
<Input
|
||||
:id="`order-${idx}`"
|
||||
v-model.number="ev.run_order"
|
||||
type="number"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex items-center gap-2 self-end">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="ev.active"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<Checkbox v-model:checked="ev.active" />
|
||||
Aktivno
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 text-sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
@click="form.events.splice(idx, 1)"
|
||||
>
|
||||
Odstrani
|
||||
</button>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
|
|
@ -639,16 +683,17 @@ const destroyDecision = () => {
|
|||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<InputLabel :for="`seg-${idx}`" value="Segment" />
|
||||
<select
|
||||
:id="`seg-${idx}`"
|
||||
v-model.number="ev.config.segment_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option :value="null">— Izberi segment —</option>
|
||||
<option v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="ev.config.segment_id">
|
||||
<SelectTrigger :id="`seg-${idx}`" class="w-full">
|
||||
<SelectValue placeholder="— Izberi segment —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Izberi segment —</SelectItem>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="form.errors[`events.${idx}.config.segment_id`]"
|
||||
class="text-xs text-red-600 mt-1"
|
||||
|
|
@ -658,11 +703,7 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="ev.config.deactivate_previous"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<Checkbox v-model:checked="ev.config.deactivate_previous" />
|
||||
Deaktiviraj prejšnje
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -672,16 +713,21 @@ const destroyDecision = () => {
|
|||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<InputLabel :for="`as-${idx}`" value="Archive setting" />
|
||||
<select
|
||||
:id="`as-${idx}`"
|
||||
v-model.number="ev.config.archive_setting_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option :value="null">— Izberi nastavitev —</option>
|
||||
<option v-for="a in archiveSettings" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="ev.config.archive_setting_id">
|
||||
<SelectTrigger :id="`as-${idx}`" class="w-full">
|
||||
<SelectValue placeholder="— Izberi nastavitev —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Izberi nastavitev —</SelectItem>
|
||||
<SelectItem
|
||||
v-for="a in archiveSettings"
|
||||
:key="a.id"
|
||||
:value="a.id"
|
||||
>
|
||||
{{ a.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="form.errors[`events.${idx}.config.archive_setting_id`]"
|
||||
class="text-xs text-red-600 mt-1"
|
||||
|
|
@ -691,11 +737,7 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="ev.config.reactivate"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<Checkbox v-model:checked="ev.config.reactivate" />
|
||||
Reactivate namesto arhiva
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -725,65 +767,63 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="form.events.push(defaultEventPayload())"
|
||||
>+ Dodaj dogodek</PrimaryButton
|
||||
>+ Dodaj dogodek</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.recentlySuccessful" class="mt-6 text-sm text-green-600">
|
||||
<div v-if="form.recentlySuccessful" class="text-sm text-green-600">
|
||||
Shranjuje.
|
||||
</div>
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeEditDrawer">Cancel</Button>
|
||||
<Button @click="update" :disabled="form.processing || !eventsValidEdit"
|
||||
>Shrani</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateDialog
|
||||
:show="drawerCreate"
|
||||
title="Dodaj odločitev"
|
||||
confirm-text="Dodaj"
|
||||
:processing="createForm.processing"
|
||||
:disabled="!eventsValidCreate"
|
||||
@close="closeCreateDrawer"
|
||||
@confirm="store"
|
||||
>
|
||||
<form @submit.prevent="store">
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="nameCreate" value="Ime" />
|
||||
<TextInput
|
||||
id="nameCreate"
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="name"
|
||||
/>
|
||||
<Dialog v-model:open="drawerCreate">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dodaj odločitev</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="nameCreate">Ime</InputLabel>
|
||||
<Input id="nameCreate" v-model="createForm.name" type="text" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<input
|
||||
id="autoMailCreate"
|
||||
type="checkbox"
|
||||
v-model="createForm.auto_mail"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="autoMailCreate" class="text-sm">Samodejna pošta (auto mail)</label>
|
||||
<Checkbox id="autoMailCreate" v-model="createForm.auto_mail" />
|
||||
<InputLabel for="autoMailCreate" class="text-sm font-normal cursor-pointer">
|
||||
Samodejna pošta (auto mail)
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4 mt-2">
|
||||
<InputLabel for="emailTemplateCreate" value="Email predloga" />
|
||||
<select
|
||||
id="emailTemplateCreate"
|
||||
<Select
|
||||
v-model="createForm.email_template_id"
|
||||
:disabled="!createForm.auto_mail"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option :value="null">— Brez —</option>
|
||||
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
<SelectTrigger id="emailTemplateCreate" class="w-full">
|
||||
<SelectValue placeholder="— Brez —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Brez —</SelectItem>
|
||||
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="createForm.email_template_id" class="text-xs text-gray-500 mt-1">
|
||||
<span
|
||||
v-if="
|
||||
|
|
@ -805,16 +845,11 @@ const destroyDecision = () => {
|
|||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="actionsCreate" value="Akcije" />
|
||||
<multiselect
|
||||
<AppMultiSelect
|
||||
id="actionsCreate"
|
||||
v-model="createForm.actions"
|
||||
:options="actionOptions"
|
||||
:multiple="true"
|
||||
track-by="id"
|
||||
:taggable="true"
|
||||
:items="actionOptions"
|
||||
placeholder="Dodaj akcijo"
|
||||
:append-to-body="true"
|
||||
label="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -830,43 +865,44 @@ const destroyDecision = () => {
|
|||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<InputLabel :for="`cevent-${idx}`" value="Dogodek" />
|
||||
<select
|
||||
:id="`cevent-${idx}`"
|
||||
v-model.number="ev.id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
@change="onEventChange(ev)"
|
||||
>
|
||||
<option :value="null">— Izberi —</option>
|
||||
<option v-for="opt in availableEvents" :key="opt.id" :value="opt.id">
|
||||
{{ opt.name || opt.key || `#${opt.id}` }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="ev.id" @update:model-value="onEventChange(ev)">
|
||||
<SelectTrigger :id="`cevent-${idx}`" class="w-full">
|
||||
<SelectValue placeholder="— Izberi —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Izberi —</SelectItem>
|
||||
<SelectItem
|
||||
v-for="opt in availableEvents"
|
||||
:key="opt.id"
|
||||
:value="opt.id"
|
||||
>
|
||||
{{ opt.name || opt.key || `#${opt.id}` }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<InputLabel :for="`corder-${idx}`" value="Vrstni red" />
|
||||
<TextInput
|
||||
<InputLabel :for="`corder-${idx}`">Vrstni red</InputLabel>
|
||||
<Input
|
||||
:id="`corder-${idx}`"
|
||||
v-model.number="ev.run_order"
|
||||
type="number"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex items-center gap-2 self-end">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="ev.active"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<Checkbox v-model:checked="ev.active" />
|
||||
Aktivno
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 text-sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
@click="createForm.events.splice(idx, 1)"
|
||||
>
|
||||
Odstrani
|
||||
</button>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
|
|
@ -874,16 +910,17 @@ const destroyDecision = () => {
|
|||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<InputLabel :for="`cseg-${idx}`" value="Segment" />
|
||||
<select
|
||||
:id="`cseg-${idx}`"
|
||||
v-model.number="ev.config.segment_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option :value="null">— Izberi segment —</option>
|
||||
<option v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="ev.config.segment_id">
|
||||
<SelectTrigger :id="`cseg-${idx}`" class="w-full">
|
||||
<SelectValue placeholder="— Izberi segment —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Izberi segment —</SelectItem>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="createForm.errors[`events.${idx}.config.segment_id`]"
|
||||
class="text-xs text-red-600 mt-1"
|
||||
|
|
@ -893,11 +930,7 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="ev.config.deactivate_previous"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<Checkbox v-model:checked="ev.config.deactivate_previous" />
|
||||
Deaktiviraj prejšnje
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -907,16 +940,21 @@ const destroyDecision = () => {
|
|||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<InputLabel :for="`cas-${idx}`" value="Archive setting" />
|
||||
<select
|
||||
:id="`cas-${idx}`"
|
||||
v-model.number="ev.config.archive_setting_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option :value="null">— Izberi nastavitev —</option>
|
||||
<option v-for="a in archiveSettings" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="ev.config.archive_setting_id">
|
||||
<SelectTrigger :id="`cas-${idx}`" class="w-full">
|
||||
<SelectValue placeholder="— Izberi nastavitev —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Izberi nastavitev —</SelectItem>
|
||||
<SelectItem
|
||||
v-for="a in archiveSettings"
|
||||
:key="a.id"
|
||||
:value="a.id"
|
||||
>
|
||||
{{ a.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="
|
||||
createForm.errors[`events.${idx}.config.archive_setting_id`]
|
||||
|
|
@ -928,11 +966,7 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="ev.config.reactivate"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<Checkbox v-model:checked="ev.config.reactivate" />
|
||||
Reactivate namesto arhiva
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -961,35 +995,42 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="createForm.events.push(defaultEventPayload())"
|
||||
>+ Dodaj dogodek</PrimaryButton
|
||||
>+ Dodaj dogodek</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="createForm.recentlySuccessful" class="mt-6 text-sm text-green-600">
|
||||
<div v-if="createForm.recentlySuccessful" class="text-sm text-green-600">
|
||||
Shranjuje.
|
||||
</div>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeCreateDrawer">Cancel</Button>
|
||||
<Button @click="store" :disabled="createForm.processing || !eventsValidCreate"
|
||||
>Dodaj</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmationModal :show="showDelete" @close="cancelDelete">
|
||||
<template #title> Delete decision </template>
|
||||
<template #content>
|
||||
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be
|
||||
undone.
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
@click="cancelDelete"
|
||||
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<PrimaryButton @click="destroyDecision">Delete</PrimaryButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
<AlertDialog v-model:open="showDelete">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete decision</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be
|
||||
undone.
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" @click="cancelDelete">Cancel</Button>
|
||||
<Button variant="destructive" @click="destroyDecision">Delete</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
|
@ -24,6 +24,18 @@
|
|||
$trail->push('Contract Configs', route('settings.contractConfigs.index'));
|
||||
});
|
||||
|
||||
// Dashboard > Settings > Archive
|
||||
Breadcrumbs::for('settings.archive.index', function (BreadcrumbTrail $trail): void {
|
||||
$trail->parent('settings');
|
||||
$trail->push('Arhiv', route('settings.archive.index'));
|
||||
});
|
||||
|
||||
// Dashboard > Settings > Reports
|
||||
Breadcrumbs::for('settings.reports.index', function (BreadcrumbTrail $trail): void {
|
||||
$trail->parent('settings');
|
||||
$trail->push('Reports', route('settings.reports.index'));
|
||||
});
|
||||
|
||||
// Dashboard
|
||||
Breadcrumbs::for('dashboard', function (BreadcrumbTrail $trail) {
|
||||
$trail->push('Nadzorna plošča', route('dashboard'));
|
||||
|
|
@ -109,6 +121,21 @@
|
|||
$trail->push('Terenska dela', route('fieldjobs.index'));
|
||||
});
|
||||
|
||||
// Dashboard > Reports
|
||||
|
||||
Breadcrumbs::for('reports.index', function (BreadcrumbTrail $trail) {
|
||||
$trail->parent('dashboard');
|
||||
$trail->push('Poročila', route('reports.index'));
|
||||
});
|
||||
|
||||
// Dashboard > Reports > [Report]
|
||||
|
||||
Breadcrumbs::for('reports.show', function (BreadcrumbTrail $trail, string $slug) {
|
||||
$trail->parent('reports.index');
|
||||
$report = \App\Models\Report::where('slug', $slug)->first();
|
||||
$trail->push($report?->name ?? $slug, route('reports.show', $slug));
|
||||
});
|
||||
|
||||
// Dashboard > Imports
|
||||
|
||||
Breadcrumbs::for('imports.index', function (BreadcrumbTrail $trail) {
|
||||
|
|
|
|||
|
|
@ -379,6 +379,33 @@
|
|||
Route::put('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'update'])->name('settings.archive.update');
|
||||
Route::post('settings/archive/{archiveSetting}/run', [ArchiveSettingController::class, 'run'])->name('settings.archive.run');
|
||||
Route::delete('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'destroy'])->name('settings.archive.destroy');
|
||||
// settings / reports settings
|
||||
Route::get('settings/reports', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'index'])->name('settings.reports.index');
|
||||
Route::get('settings/reports/{report}/edit', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'edit'])->name('settings.reports.edit');
|
||||
Route::post('settings/reports', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'store'])->name('settings.reports.store');
|
||||
Route::put('settings/reports/{report}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'update'])->name('settings.reports.update');
|
||||
Route::post('settings/reports/{report}/toggle', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'toggleEnabled'])->name('settings.reports.toggle');
|
||||
Route::delete('settings/reports/{report}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroy'])->name('settings.reports.destroy');
|
||||
// settings / reports - entities
|
||||
Route::post('settings/reports/{report}/entities', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeEntity'])->name('settings.reports.entities.store');
|
||||
Route::put('settings/reports/entities/{entity}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateEntity'])->name('settings.reports.entities.update');
|
||||
Route::delete('settings/reports/entities/{entity}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyEntity'])->name('settings.reports.entities.destroy');
|
||||
// settings / reports - columns
|
||||
Route::post('settings/reports/{report}/columns', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeColumn'])->name('settings.reports.columns.store');
|
||||
Route::put('settings/reports/columns/{column}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateColumn'])->name('settings.reports.columns.update');
|
||||
Route::delete('settings/reports/columns/{column}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyColumn'])->name('settings.reports.columns.destroy');
|
||||
// settings / reports - filters
|
||||
Route::post('settings/reports/{report}/filters', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeFilter'])->name('settings.reports.filters.store');
|
||||
Route::put('settings/reports/filters/{filter}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateFilter'])->name('settings.reports.filters.update');
|
||||
Route::delete('settings/reports/filters/{filter}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyFilter'])->name('settings.reports.filters.destroy');
|
||||
// settings / reports - conditions
|
||||
Route::post('settings/reports/{report}/conditions', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeCondition'])->name('settings.reports.conditions.store');
|
||||
Route::put('settings/reports/conditions/{condition}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateCondition'])->name('settings.reports.conditions.update');
|
||||
Route::delete('settings/reports/conditions/{condition}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyCondition'])->name('settings.reports.conditions.destroy');
|
||||
// settings / reports - orders
|
||||
Route::post('settings/reports/{report}/orders', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'storeOrder'])->name('settings.reports.orders.store');
|
||||
Route::put('settings/reports/orders/{order}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'updateOrder'])->name('settings.reports.orders.update');
|
||||
Route::delete('settings/reports/orders/{order}', [\App\Http\Controllers\Settings\ReportSettingsController::class, 'destroyOrder'])->name('settings.reports.orders.destroy');
|
||||
Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow');
|
||||
Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index');
|
||||
|
||||
|
|
@ -444,7 +471,7 @@
|
|||
Route::put('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'updateMapping'])->name('importTemplates.mappings.update');
|
||||
Route::delete('imports/templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete');
|
||||
Route::post('imports/templates{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder');
|
||||
Route::post('imports/templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
|
||||
Route::post('imports/templates/{template:uuid}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
|
||||
// Delete an unfinished import
|
||||
Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy');
|
||||
// Route::put()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user