37 Commits

Author SHA1 Message Date
Simon Pocrnjič 8147fedd04 workflow fixed multiselect, combobox width was not limited when selecting desicisions 2026-02-01 19:35:38 +01:00
Simon Pocrnjič b1c531bb70 updated sms package creator, removed result for segments with exeption true, replaced some ui elements 2026-02-01 13:43:18 +01:00
Simon Pocrnjič 9cc1b7072c added download button for orignal import csv file 2026-02-01 09:22:34 +01:00
Simon Pocrnjič 2968bcf3f8 fixed some bugs with dialog and viewing docx works again 2026-01-29 19:14:35 +01:00
Simon Pocrnjič ad0f7a7a01 checkmark for confirmed phone numbers 2026-01-28 21:32:13 +01:00
Simon Pocrnjič 368b0a7cf7 fixed some weird problem with special characters 2026-01-28 20:46:52 +01:00
Simon Pocrnjič aa375ce0da bug fixes, sms, smaller screens elements were overlaping parent containers and updated document viewer 2026-01-28 20:12:26 +01:00
Simon Pocrnjič 340e16c610 Increased post_code length varchar. 2026-01-27 21:07:48 +01:00
Simon Pocrnjič 33b236d881 Small changes 2026-01-27 19:49:09 +01:00
sipo fb7704027b Merge pull request 'production' (#1) from production into master
Reviewed-on: #1
2026-01-27 18:02:43 +00:00
Simon Pocrnjič e5902706f1 Merge remote-tracking branch 'origin/master' into Development 2026-01-27 18:42:27 +01:00
Simon Pocrnjič 229c100cc4 again added fix 2026-01-27 18:10:12 +01:00
Simon Pocrnjič 9a4897bf0c fixed normalizing decimal upsertAccount importer 2026-01-27 18:04:50 +01:00
Simon Pocrnjič d779e4d7a1 Merge branch 'master' into Development 2026-01-21 18:32:28 +01:00
Simon Pocrnjič b2a9350d0f Fixed import check for existing address 2026-01-21 18:31:54 +01:00
Simon Pocrnjič d64a67cf76 Visual changes to profile page 2026-01-19 19:24:41 +01:00
Simon Pocrnjič 068bbdf583 Updated Application icon and notifcation pagination items per page, and updated NotificationsBell 2026-01-18 19:49:48 +01:00
Simon Pocrnjič cc4c07717e Changes 2026-01-18 18:21:41 +01:00
Simon Pocrnjič 28f28be1b8 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 18:51:39 +01:00
Simon Pocrnjič 27bdb942ab Changed Import processor removed getting existing account by reference and just keep contract_id and active true 2026-01-17 17:33:19 +01:00
Simon Pocrnjič ebf9f29200 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 16:06:17 +01:00
Simon Pocrnjič 7eaab16e30 added new permission mass-archive instead if limiting mass archiving to admin users 2026-01-15 21:35:53 +01:00
Simon Pocrnjič 6a2dd860fa Mass archiving added to segment view show 2026-01-15 21:16:26 +01:00
Simon Pocrnjič 091fb07646 Update Person grid view vue and reverted import v2 back to v1 (v2 not production ready) 2026-01-15 20:38:08 +01:00
Simon Pocrnjič 357a254e82 Merge remote-tracking branch 'origin/master' into Development 2026-01-15 17:42:09 +01:00
Simon Pocrnjič aa93c96d31 ignore some .env example files 2026-01-15 17:40:43 +01:00
Simon Pocrnjič ca8754cd94 birthday normalise date 2026-01-14 22:09:04 +01:00
Simon Pocrnjič 8fdc0d6359 Changes to address added fulltext (address,post_code,city), added imployer column to person fix / updated PersonInfoGrid vue component 2026-01-14 21:38:34 +01:00
Simon Pocrnjič df6c3133ec docker setup 2026-01-14 17:33:31 +01:00
Simon Pocrnjič f646b6530a Merge remote-tracking branch 'origin/master' into Development 2026-01-12 20:25:02 +01:00
Simon Pocrnjič 7fc4520dbf Added address to client contracts table 2026-01-12 19:57:04 +01:00
Simon Pocrnjič f66bbbf842 Changes to client contract view 2026-01-12 19:38:23 +01:00
Simon Pocrnjič 4f605451e1 Merge remote-tracking branch 'origin/master' into Development 2026-01-10 20:45:59 +01:00
Simon Pocrnjič dc41862afc Client contracts view added excel export option 2026-01-10 20:36:32 +01:00
Simon Pocrnjič c4d2f6e473 Changes 2026-01-10 20:11:20 +01:00
Simon Pocrnjič 711438d79f Merge remote-tracking branch 'origin/master' into Development 2026-01-06 19:49:28 +01:00
Simon Pocrnjič fb6474ab88 changes to old import check if for account if balance_amount and initial_amount are empty or null by default value is set to 0 2026-01-06 19:39:18 +01:00
106 changed files with 8355 additions and 3461 deletions
+29
View File
@@ -0,0 +1,29 @@
.git
.gitignore
.github
.gitattributes
.env
.env.*
!.env.production.example
node_modules
npm-debug.log
vendor
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
bootstrap/cache/*
public/storage
public/hot
*.md
!README.md
tests
.phpunit.result.cache
phpunit.xml
docker-compose*.yml
.editorconfig
.styleci.yml
*.log
.DS_Store
Thumbs.db
+82
View File
@@ -0,0 +1,82 @@
APP_NAME="Teren App"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8080
APP_LOCALE=sl
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=sl_SI
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=teren_app
DB_USERNAME=teren_user
DB_PASSWORD=local_password
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
# Cache
CACHE_STORE=redis
# Mail (Mailpit for local testing)
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
SCOUT_PREFIX=
SCOUT_QUEUE=true
# Sanctum
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,localhost:8080,127.0.0.1:8080
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Vite
VITE_APP_NAME="${APP_NAME}"
VITE_DEV_SERVER_KEY=
VITE_DEV_SERVER_CERT=
# LibreOffice for document previews (Docker container path)
LIBREOFFICE_BIN=/usr/bin/soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/casesNEL=null
LOG_LEVEL=debug
# Vite
VITE_DEV_SERVER_KEY=
VITE_DEV_SERVER_CERT=
+88
View File
@@ -0,0 +1,88 @@
APP_NAME="Teren App"
APP_ENV=production
APP_KEY= # Generate with: php artisan key:generate
APP_DEBUG=false
APP_TIMEZONE=UTC
APP_URL=https://example.com # Your domain
APP_LOCALE=sl
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=sl_SI
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=teren_app
DB_USERNAME=teren_user
DB_PASSWORD= # Generate a strong password
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
# Cache
CACHE_STORE=redis
# pgAdmin
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD= # Generate a strong password
# WireGuard VPN (REQUIRED - app is VPN-only)
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
# Mail (configure as needed)
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PA
SCOUT_DRIVER=database
SCOUT_PREFIX=
SCOUT_QUEUE=true
# Sanctum
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
# Vite
VITE_APP_NAME="${APP_NAME}"
# LibreOffice for document previews (Docker container path)
LIBREOFFICE_BIN=/usr/bin/soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/cases
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
+10
View File
@@ -25,7 +25,17 @@ yarn-error.log
check-*.php
test-*.php
fix-*.php
clean-*.php
mark-*.php
# Development Documentation
IMPORT_*.md
V2_*.md
REPORTS_*.md
DEDUPLICATION_*.md
# Docker Local Testing
docker-compose.local.yaml
docker-compose.override.yaml
.env.local
.env.docker
+1045
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
ARG PHP_VERSION=8.4
FROM php:${PHP_VERSION}-fpm-alpine
# Set working directory
WORKDIR /var/www
# Install system dependencies
RUN apk add --no-cache \
git \
curl \
zip \
unzip \
supervisor \
nginx \
postgresql-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libwebp-dev \
oniguruma-dev \
libxml2-dev \
linux-headers \
${PHPIZE_DEPS}
# Configure and install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
mbstring \
exif \
pcntl \
bcmath \
gd \
opcache
# Install Redis extension via PECL
RUN pecl install redis \
&& docker-php-ext-enable redis
# Install LibreOffice from community repository
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
libreoffice-common \
libreoffice-writer \
libreoffice-calc
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create system user to run Composer and Artisan Commands
RUN addgroup -g 1000 -S www && \
adduser -u 1000 -S www -G www
# Copy application files (will be overridden by volume mount in local development)
COPY --chown=www:www . /var/www
# Copy supervisor configuration
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY docker/supervisor/conf.d /etc/supervisor/conf.d
# Set permissions
RUN chown -R www:www /var/www \
&& chmod -R 755 /var/www/storage \
&& chmod -R 755 /var/www/bootstrap/cache
# PHP Configuration for production
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Copy PHP custom configuration
COPY docker/php/custom.ini $PHP_INI_DIR/conf.d/custom.ini
# Configure PHP-FPM to listen on all interfaces (0.0.0.0) instead of just localhost
# This is needed for nginx running in a separate container to reach PHP-FPM
RUN sed -i 's/listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
# Expose port 9000 for PHP-FPM
EXPOSE 9000
# Create directories for supervisor logs
RUN mkdir -p /var/log/supervisor
# Start supervisor (which will manage both PHP-FPM and Laravel queue workers)
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
+343
View File
@@ -0,0 +1,343 @@
# Local Testing Guide - Windows/Mac/Linux
This guide helps you test the Teren App Docker setup on your local machine without WireGuard VPN.
## Quick Start
### 1. Prerequisites
- Docker Desktop installed and running
- Git
- 8GB RAM recommended
- Ports available: 8080, 5433 (PostgreSQL), 5050, 6379, 9000, 8025, 1025
- **Note:** If you have local PostgreSQL on port 5432, the Docker container uses 5433 instead
### 2. Setup
```bash
# Clone repository (if not already)
git clone YOUR_GITEA_URL
cd Teren-app
# Copy local environment file
cp .env.local.example .env
# Start all services
docker compose -f docker-compose.local.yaml up -d
# Wait for services to start (30 seconds)
timeout 30
# Generate application key
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
# Run migrations
docker compose -f docker-compose.local.yaml exec app php artisan migrate
# Seed database (optional)
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
# Install frontend dependencies (if needed)
npm install
npm run dev
```
### 3. Access Services
| Service | URL | Credentials |
|---------|-----|-------------|
| **Laravel App** | http://localhost:8080 | - |
| **Portainer** | http://localhost:9000 | Set on first visit |
| **pgAdmin** | http://localhost:5050 | admin@local.dev / admin |
| **Mailpit** | http://localhost:8025 | - |
| **PostgreSQL** | localhost:5433 | teren_user / local_password |
| **Redis** | localhost:6379 | - |
**Note:** PostgreSQL uses port 5433 to avoid conflicts with any local PostgreSQL installation.
## Common Commands
### Docker Compose Commands
```bash
# Start all services
docker compose -f docker-compose.local.yaml up -d
# Stop all services
docker compose -f docker-compose.local.yaml down
# View logs
docker compose -f docker-compose.local.yaml logs -f
# View specific service logs
docker compose -f docker-compose.local.yaml logs -f app
# Restart a service
docker compose -f docker-compose.local.yaml restart app
# Rebuild containers
docker compose -f docker-compose.local.yaml up -d --build
# Stop and remove everything (including volumes)
docker compose -f docker-compose.local.yaml down -v
```
### Laravel Commands
```bash
# Run artisan commands
docker compose -f docker-compose.local.yaml exec app php artisan [command]
# Examples:
docker compose -f docker-compose.local.yaml exec app php artisan migrate
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
docker compose -f docker-compose.local.yaml exec app php artisan cache:clear
docker compose -f docker-compose.local.yaml exec app php artisan config:clear
docker compose -f docker-compose.local.yaml exec app php artisan queue:work
# Run tests
docker compose -f docker-compose.local.yaml exec app php artisan test
# Access container shell
docker compose -f docker-compose.local.yaml exec app sh
# Run Composer commands
docker compose -f docker-compose.local.yaml exec app composer install
docker compose -f docker-compose.local.yaml exec app composer update
```
### Database Commands
```bash
# Connect to PostgreSQL (from inside container)
docker compose -f docker-compose.local.yaml exec postgres psql -U teren_user -d teren_app
# Connect from Windows host
psql -h localhost -p 5433 -U teren_user -d teren_app
# Backup database
docker compose -f docker-compose.local.yaml exec postgres pg_dump -U teren_user teren_app > backup.sql
# Restore database
docker compose -f docker-compose.local.yaml exec -T postgres psql -U teren_user teren_app < backup.sql
# Reset database
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
```
## pgAdmin Setup
1. Open http://localhost:5050
2. Login: `admin@local.dev` / `admin`
3. Add Server:
- **General Tab:**
- Name: `Teren Local`
- **Connection Tab:**
- Host: `postgres`
- Port: `5432`
- Database: `teren_app`
- Username: `teren_user`
- Passwo
**External Connection:** To connect from your Windows machine (e.g., DBeaver, pgAdmin desktop), use:
- Host: `localhost`
- Port: `5433` (not 5432)
- Database: `teren_app`
- Username: `teren_user`
- Password: `local_password`rd: `local_password`
4. Click Save
## Mailpit - Email Testing
All emails sent by the application are caught by Mailpit.
- Access: http://localhost:8025
- View all emails in the web interface
- Test email sending:
```bash
docker compose -f docker-compose.local.yaml exec app php artisan tinker
# In tinker:
Mail::raw('Test email', function($msg) {
$msg->to('test@example.com')->subject('Test');
});
```
## Portainer Setup
1. Open http://localhost:9000
2. On first visit, create admin account
3. Select "Docker" environment
4. Click "Connect"
Use Portainer to:
- View and manage containers
- Check logs
- Execute commands in containers
- Monitor resource usage
## Development Workflow
### Frontend Development
The local setup supports live reloading:
```bash
# Run Vite dev server (outside Docker)
npm run dev
# Or inside Docker
docker compose -f docker-compose.local.yaml exec app npm run dev
```
Access: http://localhost:8080
### Code Changes
All code changes are automatically reflected because the source code is mounted as a volume:
```yaml
volumes:
- ./:/var/www # Live code mounting
```
### Queue Workers
Queue workers are running via Supervisor inside the container. To restart:
```bash
# Restart queue workers
docker compose -f docker-compose.local.yaml exec app supervisorctl restart all
# Check status
docker compose -f docker-compose.local.yaml exec app supervisorctl status
# View worker logs
docker compose -f docker-compose.local.yaml exec app tail -f storage/logs/worker.log
```
## Troubleshooting
### Port Already in Use
If you get "port is already allocated" error:
```bash
# Windows - Find process using port
netstat -ano | findstr :8080
# Kill process by PID
taskkill /PID <PID> /F
# Or change port in docker-compose.local.yaml
ports:
- "8081:80" # Change 8080 to 8081
```
### Container Won't Start
```bash
# Check logs
docker compose -f docker-compose.local.yaml logs app
# Rebuild containers
docker compose -f docker-compose.local.yaml down
docker compose -f docker-compose.local.yaml up -d --build
```
### Permission Errors (Linux/Mac)
```bash
# Fix storage permissions
docker compose -f docker-compose.local.yaml exec app chown -R www:www /var/www/storage
docker compose -f docker-compose.local.yaml exec app chmod -R 775 /var/www/storage
```
### Database Connection Failed
```bash
# Check if PostgreSQL is running
docker compose -f docker-compose.local.yaml ps postgres
# Check logs
docker compose -f docker-compose.local.yaml logs postgres
# Restart PostgreSQL
docker compose -f docker-compose.local.yaml restart postgres
```
### Clear All Data and Start Fresh
```bash
# Stop and remove everything
docker compose -f docker-compose.local.yaml down -v
# Remove images
docker compose -f docker-compose.local.yaml down --rmi all
# Start fresh
docker compose -f docker-compose.local.yaml up -d --build
# Re-initialize
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
```
## Performance Tips
### Windows Performance
If using WSL2 (recommended):
1. Clone repo inside WSL2 filesystem, not Windows filesystem
2. Use WSL2 terminal for commands
3. Enable WSL2 integration in Docker Desktop settings
### Mac Performance
1. Enable VirtioFS in Docker Desktop settings
2. Disable file watching if not needed
3. Use Docker volumes for vendor directories:
```yaml
volumes:
- ./:/var/www
- /var/www/vendor # Anonymous volume for vendor
- /var/www/node_modules # Anonymous volume for node_modules
```
## Testing Production-Like Setup
To test the production VPN setup locally (advanced):
1. Enable WireGuard in `docker-compose.yaml.example`
2. Change all `10.13.13.1` bindings to `127.0.0.1`
3. Test SSL with self-signed certificates
## Differences from Production
| Feature | Local | Production |
|---------|-------|------------|
| **VPN** | No VPN | WireGuard required |
| **Port** | :8080 | :80/:443 |
| **SSL** | No SSL | Let's Encrypt |
| **Debug** | Enabled | Disabled |
| **Emails** | Mailpit | Real SMTP |
| **Logs** | Debug level | Error level |
| **Code** | Live mount | Built into image |
## Next Steps
After testing locally:
1. Review `docker-compose.yaml.example` for production
2. Follow `DEPLOYMENT_GUIDE.md` for VPS setup
3. Configure WireGuard VPN
4. Deploy to production
## Useful Resources
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Laravel Docker Documentation](https://laravel.com/docs/deployment)
- [PostgreSQL Docker](https://hub.docker.com/_/postgres)
- [Mailpit Documentation](https://github.com/axllent/mailpit)
+159
View File
@@ -0,0 +1,159 @@
# Quick Start: VPN-Only Access Setup
⚠️ **IMPORTANT:** This application is configured for VPN-ONLY access. It will NOT be publicly accessible.
## Quick Setup Steps
### 1. Install Docker (on VPS)
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
### 2. Clone & Configure
```bash
git clone YOUR_GITEA_REPO/Teren-app.git
cd Teren-app
cp docker-compose.yaml.example docker-compose.yaml
cp .env.production.example .env
```
### 3. Edit Configuration
```bash
vim .env
```
**Required changes:**
- `WG_SERVERURL` = Your VPS public IP (e.g., `123.45.67.89`)
- `WG_UI_PASSWORD` = Strong password for WireGuard dashboard
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` = Database credentials
- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` = pgAdmin credentials
### 4. Start WireGuard First
```bash
# Enable kernel module
sudo modprobe wireguard
# Start WireGuard
docker compose up -d wireguard
# Wait 10 seconds
sleep 10
# Check status
docker compose logs wireguard
```
### 5. Setup VPN Client (on your laptop/desktop)
**Access WireGuard Dashboard:** `http://YOUR_VPS_IP:51821`
1. Login with password from step 3
2. Click "New Client"
3. Name it (e.g., "MyLaptop")
4. Download config or scan QR code
**Install WireGuard Client:**
- Windows: https://www.wireguard.com/install/
- macOS: App Store
- Linux: `sudo apt install wireguard`
- Mobile: App Store / Play Store
**Import config and CONNECT**
### 6. Verify VPN Works
```bash
# From your local machine (while connected to VPN)
ping 10.13.13.1
```
Should get responses ✅
### 7. Secure WireGuard Dashboard
Edit `docker-compose.yaml`:
```yaml
# Find wireguard service, change:
ports:
- "51821:51821/tcp"
# To:
ports:
- "10.13.13.1:51821:51821/tcp"
```
```bash
docker compose down
docker compose up -d wireguard
```
### 8. Start All Services
```bash
# Make sure you're connected to VPN!
docker compose up -d
```
### 9. Initialize Application
```bash
# Generate app key
docker compose exec app php artisan key:generate
# Run migrations
docker compose exec app php artisan migrate --force
# Cache config
docker compose exec app php artisan config:cache
```
### 10. Access Your Services
**While connected to VPN:**
| Service | URL |
|---------|-----|
| **Laravel App** | http://10.13.13.1 |
| **Portainer** | http://10.13.13.1:9000 |
| **pgAdmin** | http://10.13.13.1:5050 |
| **WireGuard UI** | http://10.13.13.1:51821 |
## Firewall Configuration
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 51820/udp # WireGuard VPN
sudo ufw enable
```
**That's it!** ✅
## Adding More VPN Clients
1. Connect to VPN
2. Open: `http://10.13.13.1:51821`
3. Click "New Client"
4. Download config
5. Import on new device
## Troubleshooting
**Can't connect to VPN:**
```bash
docker compose logs wireguard
sudo ufw status
```
**Can't access app after VPN connection:**
```bash
ping 10.13.13.1
docker compose ps
docker compose logs nginx
```
**Check which ports are exposed:**
```bash
docker compose ps
sudo netstat -tulpn | grep 10.13.13.1
```
## Full Documentation
See `DEPLOYMENT_GUIDE.md` for complete setup instructions, SSL configuration, automated deployments, and troubleshooting.
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Exports;
use App\Models\Contract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
{
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
/**
* @var array<string, string>
*/
private array $columnLetterMap = [];
/**
* @var array<string, array{label: string}>
*/
public const COLUMN_METADATA = [
'reference' => ['label' => 'Referenca'],
'customer' => ['label' => 'Stranka'],
'address' => ['label' => 'Naslov'],
'start' => ['label' => 'Začetek'],
'segment' => ['label' => 'Segment'],
'balance' => ['label' => 'Stanje'],
];
/**
* @param array<int, string> $columns
*/
public function __construct(private Builder $query, private array $columns) {}
/**
* @return array<int, string>
*/
public static function allowedColumns(): array
{
return array_keys(self::COLUMN_METADATA);
}
public static function columnLabel(string $column): string
{
return self::COLUMN_METADATA[$column]['label'] ?? $column;
}
public function query(): Builder
{
return $this->query;
}
/**
* @return array<int, mixed>
*/
public function map($row): array
{
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
}
/**
* @return array<int, string>
*/
public function headings(): array
{
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
}
/**
* @return array<string, string>
*/
public function columnFormats(): array
{
$formats = [];
foreach ($this->getColumnLetterMap() as $letter => $column) {
if ($column === 'reference') {
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
continue;
}
if ($column === 'start') {
$formats[$letter] = self::DATE_EXCEL_FORMAT;
}
}
return $formats;
}
private function resolveValue(Contract $contract, string $column): mixed
{
return match ($column) {
'reference' => $contract->reference,
'customer' => optional($contract->clientCase?->person)->full_name,
'address' => optional($contract->clientCase?->person?->address)->address,
'start' => $this->formatDate($contract->start_date),
'segment' => $contract->segments?->first()?->name,
'balance' => optional($contract->account)->balance_amount,
default => null,
};
}
private function formatDate(?string $date): mixed
{
if (empty($date)) {
return null;
}
try {
$carbon = Carbon::parse($date);
return ExcelDate::dateTimeToExcel($carbon);
} catch (\Exception $e) {
return null;
}
}
/**
* @return array<string, string>
*/
private function getColumnLetterMap(): array
{
if ($this->columnLetterMap !== []) {
return $this->columnLetterMap;
}
$letter = 'A';
foreach ($this->columns as $column) {
$this->columnLetterMap[$letter] = $column;
$letter++;
}
return $this->columnLetterMap;
}
public function bindValue(Cell $cell, $value): bool
{
if (is_numeric($value)) {
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
return true;
}
return parent::bindValue($cell, $value);
}
}
@@ -12,6 +12,7 @@
use App\Models\SmsTemplate;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
@@ -23,9 +24,19 @@ class PackageController extends Controller
{
public function index(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->latest('id')
->paginate(25);
->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
]);
}
public function create(Request $request): Response
{
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
->where('active', true)
@@ -40,6 +51,7 @@ public function index(Request $request): Response
->get(['id', 'name', 'content']);
$segments = \App\Models\Segment::query()
->where('active', true)
->where('exclude', false)
->orderBy('name')
->get(['id', 'name']);
// Provide a lightweight list of recent clients with person names for filtering
@@ -58,8 +70,7 @@ public function index(Request $request): Response
})
->values();
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
return Inertia::render('Admin/Packages/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@@ -312,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'],
@@ -323,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$perPage = (int) ($request->input('per_page') ?? 25);
$query = Contract::query()
->with([
'clientCase.person.phones',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
@@ -341,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true);
});
} else {
// Only include contracts that have at least one active, non-excluded segment
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
}
if ($q = trim((string) $request->input('q'))) {
@@ -390,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
});
}
$contracts = $query->paginate($perPage);
$contracts = $query->limit(500)->get();
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
@@ -414,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
// Stranka: the client person
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
@@ -432,12 +453,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $contracts->currentPage(),
'last_page' => $contracts->lastPage(),
'per_page' => $contracts->perPage(),
'total' => $contracts->total(),
],
]);
}
+246 -46
View File
@@ -311,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid',
'contract_uuids' => 'nullable|array',
'contract_uuids.*' => 'uuid',
'create_for_all_contracts' => 'nullable|boolean',
'phone_view' => 'nullable|boolean',
'send_auto_mail' => 'sometimes|boolean',
'attachment_document_ids' => 'sometimes|array',
@@ -318,61 +321,102 @@ public function storeActivity(ClientCase $clientCase, Request $request)
]);
$isPhoneView = $attributes['phone_view'] ?? false;
$createForAll = $attributes['create_for_all_contracts'] ?? false;
$contractUuids = $attributes['contract_uuids'] ?? [];
// Map contract_uuid to contract_id within the same client case, if provided
$contractId = null;
if (! empty($attributes['contract_uuid'])) {
// Determine which contracts to process
$contractIds = [];
if ($createForAll && !empty($contractUuids)) {
// Get all contract IDs from the provided UUIDs
$contracts = Contract::withTrashed()
->whereIn('uuid', $contractUuids)
->where('client_case_id', $clientCase->id)
->get();
$contractIds = $contracts->pluck('id')->toArray();
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
// Single contract mode
$contract = Contract::withTrashed()
->where('uuid', $contractUuids[0])
->where('client_case_id', $clientCase->id)
->first();
if ($contract) {
$contractIds = [$contract->id];
}
} elseif (!empty($attributes['contract_uuid'])) {
// Legacy single contract_uuid support
$contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid'])
->where('client_case_id', $clientCase->id)
->first();
if ($contract) {
// Archived contracts are allowed: link activity regardless of active flag
$contractId = $contract->id;
$contractIds = [$contract->id];
}
}
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId,
]);
if ($isPhoneView && $contractId) {
$fieldJob = $contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->where('assigned_user_id', \Auth::id())
->orderByDesc('id')
->first();
if ($fieldJob) {
$fieldJob->update([
'added_activity' => true,
'last_activity' => $row->created_at,
]);
}
// If no contracts specified, create a single activity without contract
if (empty($contractIds)) {
$contractIds = [null];
}
logger()->info('Activity successfully inserted', $attributes);
$createdActivities = [];
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
// Auto mail dispatch (best-effort)
try {
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
// Filter attachments to those belonging to the selected contract
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
->filter()
->map(fn ($v) => (int) $v)
->values();
$validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = Document::query()
// Disable auto mail if creating activities for multiple contracts
if ($sendFlag && count($contractIds) > 1) {
$sendFlag = false;
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
}
foreach ($contractIds as $contractId) {
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId,
]);
$createdActivities[] = $row;
if ($isPhoneView && $contractId) {
$contract = Contract::find($contractId);
if ($contract) {
$fieldJob = $contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->where('assigned_user_id', \Auth::id())
->orderByDesc('id')
->first();
if ($fieldJob) {
$fieldJob->update([
'added_activity' => true,
'last_activity' => $row->created_at,
]);
}
}
}
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
// Auto mail dispatch (best-effort)
try {
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
// Filter attachments to those belonging to the selected contract
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
->filter()
->map(fn ($v) => (int) $v)
->values();
$validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = Document::query()
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
$validAttachmentIds = Document::query()
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
@@ -383,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
}
}
$activityCount = count($createdActivities);
$successMessage = $activityCount > 1
? "Successfully created {$activityCount} activities!"
: 'Successfully created activity!';
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
return back(303)->with('success', 'Successful created!')->with('flash_method', 'POST');
return back(303)->with('success', $successMessage)->with('flash_method', 'POST');
} catch (QueryException $e) {
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
@@ -1029,6 +1079,156 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
);
}
/**
* Archive multiple contracts in a batch operation
*/
public function archiveBatch(Request $request)
{
$validated = $request->validate([
'contracts' => 'required|array',
'contracts.*' => 'required|uuid|exists:contracts,uuid',
'reactivate' => 'boolean',
]);
$reactivate = $validated['reactivate'] ?? false;
// Get archive setting
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where('reactivate', $reactivate)
->orderByDesc('id')
->first();
if (! $setting) {
\Log::warning('No archive settings found for batch archive');
return back()->with('flash', [
'error' => 'No archive settings found',
]);
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$successCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($validated['contracts'] as $contractUuid) {
try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// Skip if contract is already archived (active = 0)
if (!$contract->active) {
$skippedCount++;
continue;
}
$clientCase = $contract->clientCase;
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
'account_id' => $contract->account->id ?? null,
];
// Execute archive setting
$executor->executeSetting($setting, $context, \Auth::id());
// Transaction for segment updates and activity logging
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
// Create activity log
if ($setting->action_id && $setting->decision_id) {
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'note' => ($reactivate)
? "Ponovno aktivirana pogodba $contract->reference"
: "Arhivirana pogodba $contract->reference",
];
try {
\App\Models\Activity::create($activityData);
} catch (Exception $e) {
\Log::warning('Activity could not be created during batch archive');
}
}
// Move to archive segment if specified
if ($setting->segment_id) {
$segmentId = $setting->segment_id;
// Deactivate all current segments
$contract->segments()
->allRelatedIds()
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
'active' => false,
'updated_at' => now(),
]));
// Activate archive segment
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
$contract->attachedSegments()->updateExistingPivot($segmentId, [
'active' => true,
'updated_at' => now(),
]);
} else {
$contract->segments()->attach($segmentId, [
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Cancel pending field jobs
$contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->update([
'cancelled_at' => date('Y-m-d'),
'updated_at' => now(),
]);
});
$successCount++;
} catch (Exception $e) {
\Log::error('Error archiving contract in batch', [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
]);
$errors[] = [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
];
}
}
if (count($errors) > 0) {
$message = "Archived $successCount contracts";
if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived";
}
$message .= ", " . count($errors) . " failed";
return back()->with('flash', [
'error' => $message,
'details' => $errors,
]);
}
$message = $reactivate
? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts";
if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)";
}
return back()->with('flash', [
'success' => $message,
]);
}
/**
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
*/
+85 -2
View File
@@ -2,11 +2,15 @@
namespace App\Http\Controllers;
use App\Exports\ClientContractsExport;
use App\Http\Requests\ExportClientContractsRequest;
use App\Models\Client;
use App\Services\ReferenceDataCache;
use DB;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class ClientController extends Controller
{
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
->where('clients.active', 1)
//->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
@@ -47,7 +51,7 @@ public function index(Client $client, Request $request)
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('per_page', 15))
->paginate($request->integer('per_page', default: 100))
->withQueryString(),
'filters' => $request->only(['search']),
]);
@@ -137,6 +141,7 @@ public function contracts(Client $client, Request $request)
->with([
'clientCase:id,uuid,person_id',
'clientCase.person:id,full_name',
'clientCase.person.address',
'segments' => function ($q) {
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
},
@@ -166,6 +171,84 @@ public function contracts(Client $client, Request $request)
]);
}
public function exportContracts(ExportClientContractsRequest $request, Client $client)
{
$data = $request->validated();
$columns = array_values(array_unique($data['columns']));
$from = $data['from'] ?? null;
$to = $data['to'] ?? null;
$search = $data['search'] ?? null;
$segmentsParam = $data['segments'] ?? null;
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
$query = \App\Models\Contract::query()
->whereHas('clientCase', function ($q) use ($client) {
$q->where('client_id', $client->id);
})
->with([
'clientCase:id,uuid,person_id',
'clientCase.person:id,full_name',
'clientCase.person.address',
'segments' => function ($q) {
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
},
'account:id,accounts.contract_id,balance_amount',
])
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
->whereNull('deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->where(function ($inner) use ($search) {
$inner->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
})
->when($segmentIds, function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($s) use ($segmentIds) {
$s->whereIn('segments.id', $segmentIds)
->where('contract_segment.active', true);
});
})
->orderByDesc('start_date');
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
$page = max(1, (int) ($data['page'] ?? 1));
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
$query->forPage($page, $perPage);
}
$filename = $this->buildExportFilename($client);
return Excel::download(new ClientContractsExport($query, $columns), $filename);
}
private function buildExportFilename(Client $client): string
{
$datePrefix = now()->format('dmy');
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
}
private function slugify(?string $value): string
{
if (empty($value)) {
return 'data';
}
return Str::slug($value, '-') ?: 'data';
}
public function store(Request $request)
{
+4 -2
View File
@@ -62,7 +62,8 @@ public function index(Request $request)
$unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id');
->unique('id')
->values();
$assignedContracts = Contract::query()
@@ -98,7 +99,8 @@ public function index(Request $request)
$assignedClients = $assignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id');
->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']);
+18 -5
View File
@@ -9,7 +9,6 @@
use App\Models\ImportEvent;
use App\Models\ImportTemplate;
use App\Services\CsvImportService;
use App\Services\Import\ImportServiceV2;
use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor;
use Illuminate\Http\Request;
@@ -184,12 +183,13 @@ public function store(Request $request)
}
// Kick off processing of an import - simple synchronous step for now
public function process(Import $import, Request $request, ImportServiceV2 $processor)
public function process(Import $import, Request $request, ImportProcessor $processor)
{
$import->update(['status' => 'validating', 'started_at' => now()]);
try {
$result = $processor->process($import, user: $request->user());
return response()->json($result);
} catch (\Throwable $e) {
\Log::error('Import processing failed', [
@@ -202,7 +202,7 @@ public function process(Import $import, Request $request, ImportServiceV2 $proce
return response()->json([
'success' => false,
'message' => 'Import processing failed: ' . $e->getMessage(),
'message' => 'Import processing failed: '.$e->getMessage(),
], 500);
}
}
@@ -712,8 +712,6 @@ public function simulatePayments(Import $import, Request $request)
* templates. For payments templates, payment-specific summaries/entities will be included
* automatically by the simulation service when mappings contain the payment root.
*
* @param Import $import
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function simulate(Import $import, Request $request)
@@ -829,4 +827,19 @@ public function destroy(Request $request, Import $import)
return back()->with('success', 'Import deleted successfully');
}
// Download the original import file
public function download(Import $import)
{
// Verify file exists
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
return response()->json([
'error' => 'File not found',
], 404);
}
$fileName = $import->original_name ?? 'import_'.$import->uuid;
return Storage::disk($import->disk)->download($import->path, $fileName);
}
}
@@ -19,7 +19,7 @@ public function unread(Request $request)
}
$today = now()->toDateString();
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
$search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', ''));
$clientId = null;
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Exports\ClientContractsExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportClientContractsRequest extends FormRequest
{
public const SCOPE_CURRENT = 'current';
public const SCOPE_ALL = 'all';
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$columnRule = Rule::in(ClientContractsExport::allowedColumns());
return [
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
'columns' => ['required', 'array', 'min:1'],
'columns.*' => ['string', $columnRule],
'search' => ['nullable', 'string', 'max:255'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'segments' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
]);
}
}
+2 -2
View File
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
if ($template && $case) {
$note = '';
if ($log->status === 'sent') {
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
} elseif ($log->status === 'failed') {
$note = sprintf(
'Št: %s | Telo: %s | Napaka: %s',
'Tel: %s | Telo: %s | Napaka: %s',
(string) $this->to,
(string) $this->content,
'SMS ni bil poslan!'
+2
View File
@@ -6,10 +6,12 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model
{
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory;
protected $fillable = [
+1
View File
@@ -46,6 +46,7 @@ class Person extends Model
'group_id',
'type_id',
'user_id',
'employer'
];
protected $hidden = [
@@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
$payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId;
$addressEntity = new \App\Models\Person\PersonAddress;
$addressEntity = new PersonAddress;
$addressEntity->fill($payload);
$addressEntity->save();
@@ -129,7 +129,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
protected function resolveAddress(string $address, int $personId): mixed
{
return \App\Models\Person\PersonAddress::where('person_id', $personId)
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
+66 -6
View File
@@ -24,6 +24,7 @@
use App\Models\Person\PersonPhone;
use App\Models\Person\PersonType;
use App\Models\Person\PhoneType;
use Exception;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$existing = Account::query()
->where('contract_id', $contractId)
->where('reference', $reference)
//->where('reference', $reference)
->where('active', 1)
->first();
@@ -1655,6 +1656,14 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$value = $acc[$field] ?? null;
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
$value = $this->normalizeDecimal($value);
// Ensure the normalized value is numeric, otherwise default to 0
if ($value === '' || $value === '-' || ! is_numeric($value)) {
$value = 0;
}
}
// Convert empty string to 0 for amount fields
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
$value = 0;
}
$mode = $map->apply_mode ?? 'both';
if ($mode === 'keyref') {
@@ -1684,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
if ($existing) {
// Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// Track balance change
$oldBalance = (float) ($existing->balance_amount ?? 0);
// Track balance change - normalize in case DB has malformed data
$rawBalance = $existing->balance_amount ?? 0;
if (is_string($rawBalance) && $rawBalance !== '') {
$rawBalance = $this->normalizeDecimal($rawBalance);
}
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
// Note: meta merging for contracts is handled in upsertContractChain, not here
if (! empty($changes)) {
$existing->fill($changes);
@@ -1694,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
if (array_key_exists('balance_amount', $changes)) {
$newBalance = (float) ($existing->balance_amount ?? 0);
$rawNewBalance = $existing->balance_amount ?? 0;
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
}
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
if ($newBalance !== $oldBalance) {
try {
$contractId = $existing->contract_id;
@@ -2970,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
// Create person if any fields present; ensure required foreign keys
if (! empty($p)) {
$data = [];
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id', 'employer'] as $k) {
if (array_key_exists($k, $p)) {
$data[$k] = $p[$k];
}
@@ -2983,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
$data['full_name'] = trim($fn.' '.$ln);
}
}
// normalise birthday date
if (!empty($data['birthday'])) {
try {
$data['birthday'] = date('Y-m-d', strtotime($data['birthday']));
} catch (Exception $e) {
Log::warning('ImportProcessor::findOrCreatePersonId ' . $e->getMessage());
}
}
// ensure required group/type ids
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
@@ -3159,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO';
}
if (!empty($addrData['city']) && empty($addrData['post_code'])) {
if (preg_match('/^\d{3,}\s+/',trim($addrData['city']))) {
$cleanStrCity = str($addrData['city'])->squish()->value();
$splitCity = preg_split('/\s/', $cleanStrCity, 2);
if (count($splitCity) >= 2) {
$addrData['post_code'] = $splitCity[0];
$addrData['city'] = $splitCity[1];
}
}
}
// Compare addresses with all spaces removed to handle whitespace variations
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
/*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
$existing = PersonAddress::where('person_id', $personId)
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
->first();*/
// Build search query combining address, post_code and city
$searchParts = [$addrData['address']];
if (!empty($addrData['post_code'])) {
$searchParts[] = $addrData['post_code'];
}
if (!empty($addrData['city'])) {
$searchParts[] = $addrData['city'];
}
$searchQuery = implode(' ', $searchParts);
// Use fulltext search (GIN index optimized)
$existing = PersonAddress::query()->where('person_id', $personId)
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
->first();
$applyInsert = [];
@@ -3207,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
$data['post_code'] = $addrData['post_code'];
}
try {
$created = PersonAddress::create($data);
+1 -1
View File
@@ -60,7 +60,7 @@
'features' => [
// Features::termsAndPrivacyPolicy(),
// Features::profilePhotos(),
Features::api(),
// Features::api(),
// Features::teams(['invitations' => true]),
Features::accountDeletion(),
],
@@ -0,0 +1,28 @@
<?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::table('person', function (Blueprint $table){
$table->string('employer', 125)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('person', function (Blueprint $table){
$table->dropColumn('employer');
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Add a generated tsvector column for fulltext search
DB::statement("
ALTER TABLE person_addresses
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(address, '') || ' ' ||
coalesce(post_code, '') || ' ' ||
coalesce(city, '')
)
) STORED
");
// Create GIN index on the tsvector column for fast fulltext search
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
}
public function down(): void
{
Schema::table('person_addresses', function (Blueprint $table) {
$table->dropIndex('person_addresses_search_vector_idx');
$table->dropColumn('search_vector');
});
}
};
@@ -0,0 +1,49 @@
<?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::table('person_addresses', function (Blueprint $table) {
$table->dropIndex('person_addresses_search_vector_idx');
$table->dropColumn('search_vector');
$table->string('post_code', 50)->nullable()->change();
});
// Add a generated tsvector column for fulltext search
DB::statement("
ALTER TABLE person_addresses
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(address, '') || ' ' ||
coalesce(post_code, '') || ' ' ||
coalesce(city, '')
)
) STORED
");
// Create GIN index on the tsvector column for fast fulltext search
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('person_addresses', function (Blueprint $table) {
$table->string('post_code', 20)->change();
});
}
};
+2 -1
View File
@@ -14,7 +14,7 @@ public function run(): void
'key' => 'person',
'canonical_root' => 'person',
'label' => 'Person',
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
'field_aliases' => [
'dob' => 'birthday',
'date_of_birth' => 'birthday',
@@ -30,6 +30,7 @@ public function run(): void
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
],
'ui' => ['order' => 1],
],
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Teren App Deployment Script
# This script handles automated deployment from Gitea
set -e # Exit on any error
echo "🚀 Starting deployment..."
# Configuration
PROJECT_DIR="/var/www/Teren-app"
BRANCH="main" # Change to your production branch
GITEA_REPO="git@your-gitea-server.com:username/Teren-app.git"
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Change to project directory
cd $PROJECT_DIR
echo "📥 Pulling latest changes from $BRANCH..."
git fetch origin $BRANCH
git reset --hard origin/$BRANCH
echo "🔧 Copying production environment file..."
if [ ! -f .env ]; then
echo "${RED}❌ .env file not found! Please create it from .env.production.example${NC}"
exit 1
fi
echo "🐳 Building and starting Docker containers..."
docker-compose down
docker-compose build --no-cache app
docker-compose up -d
echo "⏳ Waiting for containers to be healthy..."
sleep 10
echo "📦 Installing/updating Composer dependencies..."
docker-compose exec -T app composer install --no-dev --optimize-autoloader --no-interaction
echo "🎨 Building frontend assets..."
# If you build assets locally or in CI/CD, uncomment:
# npm ci
# npm run build
echo "🔑 Optimizing Laravel..."
docker-compose exec -T app php artisan config:cache
docker-compose exec -T app php artisan route:cache
docker-compose exec -T app php artisan view:cache
docker-compose exec -T app php artisan event:cache
echo "📊 Running database migrations..."
docker-compose exec -T app php artisan migrate --force
echo "🗄️ Clearing old caches..."
docker-compose exec -T app php artisan cache:clear
docker-compose exec -T app php artisan queue:restart
echo "🔄 Restarting queue workers..."
docker-compose restart app
echo "${GREEN}✅ Deployment completed successfully!${NC}"
# Optional: Send notification (Slack, Discord, etc.)
# curl -X POST -H 'Content-type: application/json' \
# --data '{"text":"🚀 Teren App deployed successfully!"}' \
# YOUR_WEBHOOK_URL
# Show running containers
echo "📋 Running containers:"
docker-compose ps
+189
View File
@@ -0,0 +1,189 @@
version: '3.8'
services:
# Laravel Application
app:
build:
context: .
dockerfile: Dockerfile
args:
- PHP_VERSION=8.4
container_name: teren-app
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- ./storage:/var/www/storage
- ./bootstrap/cache:/var/www/bootstrap/cache
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- DB_CONNECTION=pgsql
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- REDIS_PORT=6379
- QUEUE_CONNECTION=redis
- LIBREOFFICE_BIN=/usr/bin/soffice
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- teren-network
# Supervisor runs inside the container (defined in Dockerfile)
# Includes PHP-FPM, Laravel queue workers, and queue-sms workers
# Nginx Web Server (VPN-only access)
nginx:
image: nginx:alpine
container_name: teren-nginx
restart: unless-stopped
ports:
- "10.13.13.1:80:80" # Only accessible via WireGuard VPN
- "10.13.13.1:443:443" # Only accessible via WireGuard VPN
volumes:
- ./:/var/www
- ./docker/nginx/conf.d:/etc/nginx/conf.d
- ./docker/nginx/ssl:/etc/nginx/ssl
- ./docker/certbot/conf:/etc/letsencrypt
- ./docker/certbot/www:/var/www/certbot
depends_on:
- app
networks:
- teren-network
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
# Certbot for SSL certificates
certbot:
image: certbot/certbot
container_name: teren-certbot
restart: unless-stopped
volumes:
- ./docker/certbot/conf:/etc/letsencrypt
- ./docker/certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
- teren-network
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: teren-postgres
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432" # Only accessible via localhost (or VPN)
environment:
- POSTGRES_DB=${DB_DATABASE}
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/postgres/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- teren-network
# pgAdmin - PostgreSQL UI
pgadmin:
image: dpage/pgadmin4:latest
container_name: teren-pgadmin
restart: unless-stopped
ports:
- "127.0.0.1:5050:80" # Only accessible via localhost (or VPN)
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL:-admin@admin.com}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD:-admin}
- PGADMIN_CONFIG_SERVER_MODE=True
- PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=True
volumes:
- pgadmin-data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- teren-network
# Redis for caching and queues
redis:
image: redis:7-alpine
container_name: teren-redis
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- teren-network
# WireGuard VPN with Web UI Dashboard
wireguard:
image: weejewel/wg-easy:latest
container_name: teren-wireguard
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- WG_HOST=${WG_SERVERURL} # Your VPS public IP or domain
- PASSWORD=${WG_UI_PASSWORD} # Password for WireGuard UI
- WG_PORT=51820
- WG_DEFAULT_ADDRESS=10.13.13.x
- WG_DEFAULT_DNS=1.1.1.1,1.0.0.1
- WG_MTU=1420
- WG_PERSISTENT_KEEPALIVE=25
- WG_ALLOWED_IPS=10.13.13.0/24
volumes:
- wireguard-data:/etc/wireguard
ports:
- "51820:51820/udp" # WireGuard VPN port (public)
- "51821:51821/tcp" # WireGuard Web UI (public for initial setup, then VPN-only)
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv4.ip_forward=1
networks:
- teren-network
# Portainer - Docker Management UI (VPN-only access)
portainer:
image: portainer/portainer-ce:latest
container_name: teren-portainer
restart: unless-stopped
ports:
- "10.13.13.1:9000:9000" # Portainer UI (VPN-only)
- "10.13.13.1:9443:9443" # Portainer HTTPS (VPN-only)
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer-data:/data
networks:
- teren-network
networks:
teren-network:
driver: bridge
volumes:
postgres-data:
driver: local
pgadmin-data:
driver: local
redis-data:
driver: local
wireguard-data:
driver: local
portainer-data:
driver: local
+86
View File
@@ -0,0 +1,86 @@
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com; # Change this to your domain
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com; # Change this to your domain
root /var/www/public;
index index.php index.html;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # Change this
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # Change this
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Laravel location configuration
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
# Increase timeouts for long-running requests
fastcgi_read_timeout 300;
fastcgi_send_timeout 300;
}
location ~ /\.(?!well-known).* {
deny all;
}
# Deny access to sensitive files
location ~ /\.env {
deny all;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
gzip_disable "msie6";
client_max_body_size 100M;
}
+53
View File
@@ -0,0 +1,53 @@
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php index.html;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Laravel location configuration
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
# Increase timeouts for debugging
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
}
location ~ /\.(?!well-known).* {
deny all;
}
# Deny access to sensitive files
location ~ /\.env {
deny all;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
gzip_disable "msie6";
client_max_body_size 100M;
}
+23
View File
@@ -0,0 +1,23 @@
; PHP Custom Configuration for Production
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 512M
max_execution_time = 300
max_input_time = 300
; OPcache settings
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1
; Production settings
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
@@ -0,0 +1,25 @@
[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --timeout=300 --verbose
autostart=true
autorestart=true
user=www
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/worker.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
stopwaitsecs=360
[program:laravel-queue-sms]
process_name=%(program_name)s_%(process_num)02d
command=/usr/local/bin/php /var/www/artisan queue:work --queue=sms --sleep=3 --tries=3 --timeout=90 --verbose
autostart=true
autorestart=true
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/worker-sms.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
stopwaitsecs=360
+11
View File
@@ -0,0 +1,11 @@
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf
autostart=true
autorestart=true
priority=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stdout_events_enabled=true
stderr_events_enabled=true
+19
View File
@@ -0,0 +1,19 @@
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
childlogdir=/var/log/supervisor
user=root
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[include]
files = /etc/supervisor/conf.d/*.conf
+88 -88
View File
@@ -1,118 +1,118 @@
<script setup>
import { ref, reactive, nextTick } from 'vue';
import DialogModal from './DialogModal.vue';
import InputError from './InputError.vue';
import PrimaryButton from './PrimaryButton.vue';
import SecondaryButton from './SecondaryButton.vue';
import TextInput from './TextInput.vue';
import { ref, reactive, nextTick } from "vue";
import DialogModal from "./DialogModal.vue";
import InputError from "./InputError.vue";
import PrimaryButton from "./PrimaryButton.vue";
import SecondaryButton from "./SecondaryButton.vue";
import { Input } from "@/Components/ui/input";
const emit = defineEmits(['confirmed']);
const emit = defineEmits(["confirmed"]);
defineProps({
title: {
type: String,
default: 'Confirm Password',
},
content: {
type: String,
default: 'For your security, please confirm your password to continue.',
},
button: {
type: String,
default: 'Confirm',
},
title: {
type: String,
default: "Confirm Password",
},
content: {
type: String,
default: "For your security, please confirm your password to continue.",
},
button: {
type: String,
default: "Confirm",
},
});
const confirmingPassword = ref(false);
const form = reactive({
password: '',
error: '',
processing: false,
password: "",
error: "",
processing: false,
});
const passwordInput = ref(null);
const startConfirmingPassword = () => {
axios.get(route('password.confirmation')).then(response => {
if (response.data.confirmed) {
emit('confirmed');
} else {
confirmingPassword.value = true;
axios.get(route("password.confirmation")).then((response) => {
if (response.data.confirmed) {
emit("confirmed");
} else {
confirmingPassword.value = true;
setTimeout(() => passwordInput.value.focus(), 250);
}
});
setTimeout(() => passwordInput.value.focus(), 250);
}
});
};
const confirmPassword = () => {
form.processing = true;
form.processing = true;
axios.post(route('password.confirm'), {
password: form.password,
}).then(() => {
form.processing = false;
axios
.post(route("password.confirm"), {
password: form.password,
})
.then(() => {
form.processing = false;
closeModal();
nextTick().then(() => emit('confirmed'));
}).catch(error => {
form.processing = false;
form.error = error.response.data.errors.password[0];
passwordInput.value.focus();
closeModal();
nextTick().then(() => emit("confirmed"));
})
.catch((error) => {
form.processing = false;
form.error = error.response.data.errors.password[0];
passwordInput.value.focus();
});
};
const closeModal = () => {
confirmingPassword.value = false;
form.password = '';
form.error = '';
confirmingPassword.value = false;
form.password = "";
form.error = "";
};
</script>
<template>
<span>
<span @click="startConfirmingPassword">
<slot />
</span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
<div class="mt-4">
<TextInput
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="confirmPassword"
/>
<InputError :message="form.error" class="mt-2" />
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="confirmPassword"
>
{{ button }}
</PrimaryButton>
</template>
</DialogModal>
<span>
<span @click="startConfirmingPassword">
<slot />
</span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
<div class="mt-4">
<Input
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="confirmPassword"
/>
<InputError :message="form.error" class="mt-2" />
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="confirmPassword"
>
{{ button }}
</PrimaryButton>
</template>
</DialogModal>
</span>
</template>
@@ -462,6 +462,17 @@ function keyOf(row) {
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
// Expose methods for parent component
defineExpose({
clearSelection: () => {
table.resetRowSelection();
rowSelection.value = {};
},
getSelectedRows: () => {
return Object.keys(rowSelection.value).filter((key) => rowSelection.value[key]);
},
});
</script>
<template>
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog v-model:open="open">
<DialogContent :class="maxWidthClass">
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<div class="flex items-center gap-2">
@@ -6,34 +6,40 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog';
import { Button } from '@/Components/ui/button';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { ref, watch } from 'vue';
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ref, watch } from "vue";
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: 'Izbriši' },
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
confirmText: { type: String, default: 'Izbriši' },
cancelText: { type: String, default: 'Prekliči' },
title: { type: String, default: "Izbriši" },
message: {
type: String,
default: "Ali ste prepričani, da želite izbrisati ta element?",
},
confirmText: { type: String, default: "Izbriši" },
cancelText: { type: String, default: "Prekliči" },
processing: { type: Boolean, default: false },
itemName: { type: String, default: null }, // Optional name to show in confirmation
});
const emit = defineEmits(['update:show', 'close', 'confirm']);
const emit = defineEmits(["update:show", "close", "confirm"]);
const open = ref(props.show);
watch(() => props.show, (newVal) => {
open.value = newVal;
});
watch(
() => props.show,
(newVal) => {
open.value = newVal;
}
);
watch(open, (newVal) => {
emit('update:show', newVal);
emit("update:show", newVal);
if (!newVal) {
emit('close');
emit("close");
}
});
@@ -42,7 +48,7 @@ const onClose = () => {
};
const onConfirm = () => {
emit('confirm');
emit("confirm");
};
</script>
@@ -59,8 +65,13 @@ const onConfirm = () => {
<DialogDescription>
<div class="flex items-start gap-4 pt-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" />
<div
class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
>
<FontAwesomeIcon
:icon="faTriangleExclamation"
class="h-6 w-6 text-red-600"
/>
</div>
</div>
<div class="flex-1 space-y-2">
@@ -70,9 +81,7 @@ const onConfirm = () => {
<p v-if="itemName" class="text-sm font-medium text-gray-900">
{{ itemName }}
</p>
<p class="text-sm text-gray-500">
Ta dejanje ni mogoče razveljaviti.
</p>
<p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
</div>
</div>
</DialogDescription>
@@ -82,15 +91,10 @@ const onConfirm = () => {
<Button variant="outline" @click="onClose" :disabled="processing">
{{ cancelText }}
</Button>
<Button
variant="destructive"
@click="onConfirm"
:disabled="processing"
>
<Button variant="destructive" @click="onConfirm" :disabled="processing">
{{ confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog v-model:open="open">
<DialogContent :class="maxWidthClass">
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<div class="flex items-center gap-2">
@@ -1,15 +1,27 @@
<script setup>
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form'
import { Input } from '@/Components/ui/input'
import { Textarea } from '@/Components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
import { Switch } from '@/Components/ui/switch'
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { ref, watch } from "vue";
import { router } from "@inertiajs/vue3";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Switch } from "@/Components/ui/switch";
const props = defineProps({
show: { type: Boolean, default: false },
@@ -17,112 +29,128 @@ const props = defineProps({
// Optional list of contracts to allow attaching the document directly to a contract
// Each item should have at least: { uuid, reference }
contracts: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'uploaded'])
});
const emit = defineEmits(["close", "uploaded"]);
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
const ALLOWED_EXTS = [
"doc",
"docx",
"pdf",
"txt",
"csv",
"xls",
"xlsx",
"jpeg",
"jpg",
"png",
];
const formSchema = toTypedSchema(z.object({
name: z.string().min(1, 'Ime je obvezno'),
description: z.string().optional(),
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'),
is_public: z.boolean().default(true),
contract_uuid: z.string().nullable().optional(),
}))
const formSchema = toTypedSchema(
z.object({
name: z.string().min(1, "Ime je obvezno"),
description: z.string().optional(),
file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
is_public: z.boolean().default(true),
contract_uuid: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
name: '',
description: '',
name: "",
description: "",
file: null,
is_public: true,
contract_uuid: null,
},
})
});
const localError = ref('')
const localError = ref("");
watch(() => props.show, (v) => {
if (!v) return
localError.value = ''
form.resetForm()
})
watch(
() => props.show,
(v) => {
if (!v) return;
localError.value = "";
form.resetForm();
}
);
const onFileChange = (e) => {
localError.value = ''
const f = e.target.files?.[0]
localError.value = "";
const f = e.target.files?.[0];
if (!f) {
form.setFieldValue('file', null)
return
form.setFieldValue("file", null);
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase()
const ext = (f.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
e.target.value = ''
form.setFieldValue('file', null)
return
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
e.target.value = "";
form.setFieldValue("file", null);
return;
}
if (f.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
e.target.value = ''
form.setFieldValue('file', null)
return
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
e.target.value = "";
form.setFieldValue("file", null);
return;
}
form.setFieldValue('file', f)
form.setFieldValue("file", f);
if (!form.values.name) {
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
}
}
};
const submit = form.handleSubmit(async (values) => {
localError.value = ''
localError.value = "";
if (!values.file) {
localError.value = 'Prosimo izberite datoteko.'
return
localError.value = "Prosimo izberite datoteko.";
return;
}
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
const ext = (values.file.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
return
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
return;
}
if (values.file.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
return
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
return;
}
const formData = new FormData()
formData.append('name', values.name)
formData.append('description', values.description || '')
formData.append('file', values.file)
formData.append('is_public', values.is_public ? '1' : '0')
const formData = new FormData();
formData.append("name", values.name);
formData.append("description", values.description || "");
formData.append("file", values.file);
formData.append("is_public", values.is_public ? "1" : "0");
if (values.contract_uuid) {
formData.append('contract_uuid', values.contract_uuid)
formData.append("contract_uuid", values.contract_uuid);
}
router.post(props.postUrl, formData, {
forceFormData: true,
onSuccess: () => {
emit('uploaded')
emit('close')
form.resetForm()
emit("uploaded");
emit("close");
form.resetForm();
},
onError: (errors) => {
// Set form errors if any
if (errors.name) form.setFieldError('name', errors.name)
if (errors.description) form.setFieldError('description', errors.description)
if (errors.file) form.setFieldError('file', errors.file)
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
if (errors.name) form.setFieldError("name", errors.name);
if (errors.description) form.setFieldError("description", errors.description);
if (errors.file) form.setFieldError("file", errors.file);
if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
},
})
})
});
});
const close = () => emit('close')
const close = () => emit("close");
const onConfirm = () => {
submit()
}
submit();
};
</script>
<template>
@@ -137,7 +165,11 @@ const onConfirm = () => {
@confirm="onConfirm"
>
<form @submit.prevent="submit" class="space-y-4">
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid">
<FormField
v-if="props.contracts && props.contracts.length"
v-slot="{ value, handleChange }"
name="contract_uuid"
>
<FormItem>
<FormLabel>Pripiši k</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
@@ -148,11 +180,7 @@ const onConfirm = () => {
</FormControl>
<SelectContent>
<SelectItem :value="null">Primer</SelectItem>
<SelectItem
v-for="c in props.contracts"
:key="c.uuid"
:value="c.uuid"
>
<SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
Pogodba: {{ c.reference }}
</SelectItem>
</SelectContent>
@@ -165,7 +193,11 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Ime</FormLabel>
<FormControl>
<Input id="doc_name" v-bind="componentField" />
<Input
id="doc_name"
v-bind="componentField"
class="w-full max-w-full overflow-hidden text-ellipsis"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -184,29 +216,24 @@ const onConfirm = () => {
<FormField v-slot="{ value, handleChange }" name="file">
<FormItem>
<FormLabel>Datoteka (max 25MB)</FormLabel>
<FormControl>
<FormControl class="flex w-full">
<Input
id="doc_file"
type="file"
@change="onFileChange"
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
class="min-w-0 w-full"
/>
</FormControl>
<FormMessage />
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
<div v-if="value" class="text-sm text-gray-600 mt-1">
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
</div>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="is_public">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Javno</FormLabel>
@@ -1,30 +1,219 @@
<script setup>
import { ref, computed, watch } from "vue";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog'
import { Button } from '@/Components/ui/button'
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({
show: { type: Boolean, default: false },
src: { type: String, default: '' },
title: { type: String, default: 'Dokument' }
})
const emit = defineEmits(['close'])
src: { type: String, default: "" },
title: { type: String, default: "Dokument" },
mimeType: { type: String, default: "" },
filename: { type: String, default: "" },
});
const emit = defineEmits(["close"]);
const textContent = ref("");
const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
const fileExtension = computed(() => {
if (props.filename) {
return props.filename.split(".").pop()?.toLowerCase() || "";
}
return "";
});
const viewerType = computed(() => {
const ext = fileExtension.value;
const mime = props.mimeType.toLowerCase();
if (ext === "pdf" || mime === "application/pdf") return "pdf";
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
return "docx";
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
return "image";
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
return "unsupported";
});
const loadTextContent = async () => {
if (!props.src || viewerType.value !== "text") return;
loading.value = true;
try {
const response = await axios.get(props.src);
textContent.value = response.data;
} catch (e) {
textContent.value = "Napaka pri nalaganju vsebine.";
} finally {
loading.value = false;
}
};
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
// we poll until it's available.
const docxPreviewUrl = ref("");
const loadDocxPreview = async () => {
if (!props.src || viewerType.value !== "docx") return;
previewGenerating.value = true;
previewError.value = "";
docxPreviewUrl.value = "";
const maxRetries = 15;
const retryDelay = 2000; // 2 seconds between retries
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await axios.head(props.src, { validateStatus: () => true });
if (response.status >= 200 && response.status < 300) {
// Preview is ready
docxPreviewUrl.value = props.src;
previewGenerating.value = false;
return;
} else if (response.status === 202) {
// Preview is being generated, wait and retry
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
// Other error
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
} catch (e) {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
}
// Max retries reached
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
previewGenerating.value = false;
};
watch(
() => [props.show, props.src],
([show]) => {
if (show && viewerType.value === "text") {
loadTextContent();
}
if (show && viewerType.value === "docx") {
loadDocxPreview();
}
// Reset states when dialog closes
if (!show) {
previewGenerating.value = false;
previewError.value = "";
docxPreviewUrl.value = "";
}
},
{ immediate: true }
);
</script>
<template>
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
<DialogContent class="max-w-4xl">
<DialogContent class="max-w-full xl:max-w-7xl">
<DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle>
<DialogTitle>
{{ title }}
</DialogTitle>
<DialogDescription>
<Badge>
{{ fileExtension }}
</Badge>
</DialogDescription>
</DialogHeader>
<div class="h-[70vh]">
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
<div class="h-[70vh] overflow-auto">
<!-- PDF Viewer (browser native) -->
<template v-if="viewerType === 'pdf' && props.src">
<iframe
:src="props.src"
class="w-full h-full rounded border"
type="application/pdf"
/>
</template>
<!-- DOCX Viewer (converted to PDF by backend) -->
<template v-else-if="viewerType === 'docx'">
<!-- Loading/generating state -->
<div
v-if="previewGenerating"
class="flex flex-col items-center justify-center h-full gap-4"
>
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
</div>
<!-- Error state -->
<div
v-else-if="previewError"
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>{{ previewError }}</span>
<Button as="a" :href="props.src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
<!-- Preview ready -->
<iframe
v-else-if="docxPreviewUrl"
:src="docxPreviewUrl"
class="w-full h-full rounded border"
type="application/pdf"
/>
</template>
<!-- Image Viewer -->
<template v-else-if="viewerType === 'image' && props.src">
<img
:src="props.src"
:alt="props.title"
class="max-w-full max-h-full mx-auto object-contain"
/>
</template>
<!-- Text/CSV/XML Viewer -->
<template v-else-if="viewerType === 'text'">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="animate-pulse text-gray-500">Nalaganje...</div>
</div>
<pre
v-else
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
>{{ textContent }}</pre
>
</template>
<!-- Unsupported -->
<template v-else-if="viewerType === 'unsupported'">
<div
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>Predogled ni na voljo za to vrsto datoteke.</span>
<Button as="a" :href="props.src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
</template>
<!-- No source -->
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
</div>
<div class="flex justify-end mt-4">
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
</div>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import type { LucideIcon } from "lucide-vue-next";
import { ChevronRight } from "lucide-vue-next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/Components/ui/sidebar";
defineProps<{
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}>();
</script>
<template>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<Collapsible
v-for="item in items"
:key="item.title"
as-child
:default-open="item.isActive"
class="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<a :href="subItem.url">
<span>{{ subItem.title }}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</template>
@@ -7,12 +7,7 @@ import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -97,7 +92,7 @@ watch(
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "",
});
return;
@@ -108,52 +103,51 @@ watch(
{ immediate: true }
);
watch(() => props.show, (val) => {
if (val && props.edit && props.id) {
const a = props.person.addresses?.find((x) => x.id === props.id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
watch(
() => props.show,
(val) => {
if (val && props.edit && props.id) {
const a = props.person.addresses?.find((x) => x.id === props.id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "",
});
}
} else if (val && !props.edit) {
resetForm();
}
} else if (val && !props.edit) {
resetForm();
}
});
);
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.address.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
router.post(route("person.address.create", props.person), values, {
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
});
};
const update = async () => {
@@ -223,7 +217,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
<Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -233,7 +232,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
<Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -243,7 +247,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
<Input
type="text"
placeholder="Poštna številka"
autocomplete="postal-code"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -253,7 +262,22 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Mesto</FormLabel>
<FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
<Input
type="text"
placeholder="Mesto"
autocomplete="address-level2"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -85,7 +80,7 @@ const hydrate = () => {
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "",
});
return;
@@ -94,10 +89,17 @@ const hydrate = () => {
resetForm();
};
watch(() => props.id, () => hydrate(), { immediate: true });
watch(() => props.show, (v) => {
if (v) hydrate();
});
watch(
() => props.id,
() => hydrate(),
{ immediate: true }
);
watch(
() => props.show,
(v) => {
if (v) hydrate();
}
);
const update = async () => {
processing.value = true;
@@ -157,7 +159,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
<Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -167,7 +174,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
<Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -177,7 +189,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
<Input
type="text"
placeholder="Poštna številka"
autocomplete="postal-code"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -187,7 +204,22 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Mesto</FormLabel>
<FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
<Input
type="text"
placeholder="Mesto"
autocomplete="address-level2"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<Card class="p-2 gap-1" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap gap-2">
<Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-1">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
>
@@ -61,13 +61,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenu>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
<p class="font-medium text-gray-900 leading-relaxed p-1">
{{
address.post_code && address.city
? `${address.address}, ${address.post_code} ${address.city}`
: address.address
}}
</p>
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
{{ address.description }}
</p>
</Card>
<button
v-if="edit"
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getEmails(person).length">
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between" v-if="edit">
<div class="flex flex-wrap gap-1">
<span
v-if="email?.label"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
@@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
</div>
</div>
<div class="p-1">
<p class="text-sm font-medium text-gray-900 leading-relaxed">
<p class="font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }}
</p>
<p
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
<template>
<Tabs v-model="activeTab" class="mt-2">
<TabsList class="flex w-full bg-white gap-2 p-1">
<TabsList class="flex flex-row flex-wrap bg-white gap-2 p-1">
<TabsTrigger
value="person"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
@@ -384,6 +384,7 @@ const switchToTab = (tab) => {
</TabsList>
<TabsContent value="person" class="py-2">
<PersonInfoPersonTab
:is-client-case="clientCaseUuid ? true : false"
:person="person"
:edit="edit"
:person-edit="personEdit"
@@ -1,14 +1,16 @@
<script setup>
import { UserEditIcon } from "@/Utilities/Icons";
import { Button } from "../ui/button";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({
person: Object,
isClientCase: { type: Boolean, default: false },
edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true },
});
const emit = defineEmits(['edit']);
const emit = defineEmits(["edit"]);
const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
};
const handleEdit = () => {
emit('edit');
emit("edit");
};
</script>
@@ -44,51 +46,126 @@ const handleEdit = () => {
>
<UserEditIcon size="md" />
<span>Uredi</span>
</button>
</Button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Primer ref.
</p>
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Naziv</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.full_name }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Davčna
</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Emšo</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.social_security_number }}
</p>
</div>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p>
<div
v-if="isClientCase"
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"
>
<div
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Naslov
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Telefon
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Dat. rojstva
</p>
<p class="text-sm font-medium text-gray-900">
{{ fmtDateDMY(person.birthday) }}
</p>
</div>
</div>
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Naslov
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Telefon
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
</div>
<div
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3"
:class="[isClientCase ? 'md:grid-cols-2' : '']"
>
<div
v-if="isClientCase"
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Delodajalec
</p>
<p class="text-sm font-medium text-gray-900">
{{ person.employer }}
</p>
</div>
<div
class="md:col-span-full rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
:class="[isClientCase ? 'lg:col-span-1' : '']"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Opis</p>
<p class="text-sm font-medium text-gray-900">
{{ person.description }}
</p>
</div>
</div>
</template>
@@ -8,7 +8,13 @@ import {
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
import {
CircleCheckBigIcon,
CircleCheckIcon,
EllipsisVertical,
MessageSquare,
MessageSquareText,
} from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getPhones(person).length">
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap gap-2">
<Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-1">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
>
@@ -79,8 +85,12 @@ const handleSms = (phone) => emit("sms", phone);
</DropdownMenu>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
<p class="font-medium leading-relaxed p-1 flex gap-1 items-center">
{{ phone.nu }}
<CircleCheckBigIcon color="#3e9392" size="20" v-if="phone.validated" />
</p>
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
{{ phone.description }}
</p>
</Card>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { ref, watch, computed } from "vue";
import axios from "axios";
import {
Dialog,
DialogContent,
@@ -301,27 +302,13 @@ const updateSmsFromSelection = async () => {
const url = route("clientCase.sms.preview", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN":
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
"",
},
body: JSON.stringify({
template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null,
}),
credentials: "same-origin",
const { data } = await axios.post(url, {
template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null,
});
if (res.ok) {
const data = await res.json();
if (typeof data?.content === "string" && data.content.trim() !== "") {
form.setFieldValue("message", data.content);
return;
}
if (typeof data?.content === "string" && data.content.trim() !== "") {
form.setFieldValue("message", data.content);
return;
}
} catch (e) {
// ignore and fallback
@@ -1,182 +1,205 @@
<script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from '@inertiajs/vue3';
import { ref } from 'vue';
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { router } from "@inertiajs/vue3";
import { ref } from "vue";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import DatePicker from "../DatePicker.vue";
const props = defineProps({
show: {
type: Boolean,
default: false
},
person: Object
show: {
type: Boolean,
default: false,
},
person: Object,
});
const processingUpdate = ref(false);
const emit = defineEmits(['close']);
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(),
social_security_number: z.string().optional(),
birthday: z.string().optional(),
description: z.string().optional(),
employer: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || "",
birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
},
});
const close = () => {
emit('close');
setTimeout(() => {
form.resetForm({
values: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
}
});
}, 500);
}
emit("close");
setTimeout(() => {
form.resetForm({
values: {
full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || "",
birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
},
});
}, 500);
};
const updatePerson = async () => {
processingUpdate.value = true;
const { values } = form;
processingUpdate.value = true;
const { values } = form;
router.put(
route('person.update', props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
processingUpdate.value = false;
close();
},
onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processingUpdate.value = false;
},
onFinish: () => {
processingUpdate.value = false;
},
}
);
}
router.put(route("person.update", props.person), values, {
preserveScroll: true,
onSuccess: () => {
processingUpdate.value = false;
close();
},
onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processingUpdate.value = false;
},
onFinish: () => {
processingUpdate.value = false;
},
});
};
const onSubmit = form.handleSubmit(() => {
updatePerson();
});
const onConfirm = () => {
onSubmit();
}
onSubmit();
};
</script>
<template>
<UpdateDialog
:show="show"
:title="`Posodobi ${person.full_name}`"
confirm-text="Shrani"
:processing="processingUpdate"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>
Oseba
</template>
</SectionTitle>
<UpdateDialog
:show="show"
:title="`Posodobi ${person.full_name}`"
confirm-text="Shrani"
:processing="processingUpdate"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Oseba </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="full_name">
<FormItem>
<FormLabel>Naziv</FormLabel>
<FormControl>
<Input
id="cfullname"
type="text"
placeholder="Naziv"
autocomplete="full-name"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="full_name">
<FormItem>
<FormLabel>Naziv</FormLabel>
<FormControl>
<Input
id="cfullname"
type="text"
placeholder="Naziv"
autocomplete="full-name"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="tax_number">
<FormItem>
<FormLabel>Davčna</FormLabel>
<FormControl>
<Input
id="ctaxnumber"
type="text"
placeholder="Davčna številka"
autocomplete="tax-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="tax_number">
<FormItem>
<FormLabel>Davčna</FormLabel>
<FormControl>
<Input
id="ctaxnumber"
type="text"
placeholder="Davčna številka"
autocomplete="tax-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="social_security_number">
<FormItem>
<FormLabel>Matična / Emšo</FormLabel>
<FormControl>
<Input
id="csocialSecurityNumber"
type="text"
placeholder="Matična / Emšo"
autocomplete="social-security-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="social_security_number">
<FormItem>
<FormLabel>Matična / Emšo</FormLabel>
<FormControl>
<Input
id="csocialSecurityNumber"
type="text"
placeholder="Matična / Emšo"
autocomplete="social-security-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Textarea
id="cdescription"
placeholder="Opis"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
<FormField v-slot="{ componentField }" name="employer">
<FormItem>
<FormLabel>Delodajalec</FormLabel>
<FormControl>
<Input
id="cemployer"
type="text"
placeholder="Delodajalec"
autocomplete="employer"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="birthday">
<FormItem>
<FormLabel>Datum rojstva</FormLabel>
<FormControl>
<DatePicker
id="cbirthday"
:model-value="value"
@update:model-value="handleChange"
format="dd.MM.yyyy"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Textarea id="cdescription" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -101,29 +96,25 @@ const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.phone.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
router.post(route("person.phone.create", props.person), values, {
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
});
};
const onSubmit = form.handleSubmit(() => {
@@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
<Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
<SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
@@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
<SelectItem
v-for="option in phoneTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
@@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -108,7 +103,7 @@ function hydrateFromProps() {
form.setValues({
nu: p.nu || "",
country_code: p.country_code ?? 386,
type_id: p.type_id ?? (props.types?.[0]?.id ?? null),
type_id: p.type_id ?? props.types?.[0]?.id ?? null,
description: p.description || "",
validated: !!p.validated,
phone_type: p.phone_type ?? null,
@@ -119,8 +114,17 @@ function hydrateFromProps() {
resetForm();
}
watch(() => props.id, () => hydrateFromProps(), { immediate: true });
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
watch(
() => props.id,
() => hydrateFromProps(),
{ immediate: true }
);
watch(
() => props.show,
(val) => {
if (val) hydrateFromProps();
}
);
const update = async () => {
processing.value = true;
@@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
<Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
<SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
@@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
<SelectItem
v-for="option in phoneTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
@@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v;
});
if (labels.length <= 3) return labels.join(', ');
const firstThree = labels.slice(0, 3).join(', ');
if (labels.length <= 3) return labels.join(", ");
const firstThree = labels.slice(0, 3).join(", ");
const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
});
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
:variant="chipVariant"
class="flex items-center gap-1"
>
<span class="truncate max-w-[140px]">
<span class="truncate max-w-35">
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
</span>
<button
@@ -0,0 +1,177 @@
<script setup>
import { CalendarIcon, XIcon } from "lucide-vue-next";
import { computed, ref } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { RangeCalendar } from "@/Components/ui/range-calendar";
import {
DateFormatter,
getLocalTimeZone,
today,
parseDate,
CalendarDate,
} from "@internationalized/date";
const props = defineProps({
modelValue: {
type: Object,
default: () => ({ start: null, end: null }),
},
placeholder: {
type: String,
default: "Izberi datumski obseg",
},
disabled: {
type: Boolean,
default: false,
},
buttonClass: {
type: String,
default: "w-[280px]",
},
locale: {
type: String,
default: "sl-SI",
},
numberOfMonths: {
type: Number,
default: 2,
},
minValue: {
type: Object,
default: undefined,
},
maxValue: {
type: Object,
default: undefined,
},
clearable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const df = new DateFormatter(props.locale, {
dateStyle: "medium",
});
// Check if there's a selected value
const hasValue = computed(() => {
const val = props.modelValue;
return val?.start || val?.end;
});
// Convert string dates to CalendarDate objects for the calendar
const calendarValue = computed({
get() {
const val = props.modelValue;
if (!val) return undefined;
let start = null;
let end = null;
if (val.start) {
if (typeof val.start === "string") {
start = parseDate(val.start);
} else if (val.start instanceof CalendarDate) {
start = val.start;
}
}
if (val.end) {
if (typeof val.end === "string") {
end = parseDate(val.end);
} else if (val.end instanceof CalendarDate) {
end = val.end;
}
}
if (!start && !end) return undefined;
return { start, end };
},
set(newValue) {
if (!newValue) {
emit("update:modelValue", { start: null, end: null });
return;
}
// Convert CalendarDate to ISO string (YYYY-MM-DD) for easier handling
const result = {
start: newValue.start ? newValue.start.toString() : null,
end: newValue.end ? newValue.end.toString() : null,
};
emit("update:modelValue", result);
// Close popover when both dates are selected
if (result.start && result.end) {
open.value = false;
}
},
});
const displayText = computed(() => {
const val = calendarValue.value;
if (!val?.start) return props.placeholder;
const startFormatted = df.format(val.start.toDate(getLocalTimeZone()));
if (!val.end) return startFormatted;
const endFormatted = df.format(val.end.toDate(getLocalTimeZone()));
return `${startFormatted} - ${endFormatted}`;
});
function clearValue(event) {
event.stopPropagation();
emit("update:modelValue", { start: null, end: null });
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
:disabled="disabled"
:class="
cn(
'justify-start text-left font-normal',
!calendarValue?.start && 'text-muted-foreground',
buttonClass
)
"
>
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
<span class="truncate flex-1">{{ displayText }}</span>
<span
v-if="clearable && hasValue && !disabled"
class="ml-2 shrink-0 opacity-50 hover:opacity-100 cursor-pointer"
@click.stop.prevent="clearValue"
>
<XIcon class="h-4 w-4" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<RangeCalendar
v-model="calendarValue"
:locale="locale"
:number-of-months="numberOfMonths"
:min-value="minValue"
:max-value="maxValue"
initial-focus
@update:start-value="
(startDate) => {
if (calendarValue?.start?.toString() !== startDate?.toString()) {
calendarValue = { start: startDate, end: undefined };
}
}
"
/>
</PopoverContent>
</Popover>
</template>
@@ -1,178 +0,0 @@
<script setup lang="ts">
import type { SidebarProps } from "@/Components/ui/sidebar";
import {
AudioWaveform,
BookOpen,
Bot,
Command,
Frame,
GalleryVerticalEnd,
Map,
PieChart,
Settings2,
SquareTerminal,
} from "lucide-vue-next";
import NavMain from "@/Components/app/ui/layout/NavMain.vue";
import NavProjects from "@/Components/app/ui/layout/NavProjects.vue";
import NavUser from "@/Components/app/ui/layout/NavUser.vue";
import TeamSwitcher from "@/Components/app/ui/layout/TeamSwitcher.vue";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/Components/ui/sidebar";
const props = withDefaults(defineProps<SidebarProps>(), {
collapsible: "icon",
});
// This is sample data.
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
teams: [
{
name: "Acme Inc",
logo: GalleryVerticalEnd,
plan: "Enterprise",
},
{
name: "Acme Corp.",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
plan: "Free",
},
],
navMain: [
{
title: "Playground",
url: "#",
icon: SquareTerminal,
isActive: true,
items: [
{
title: "History",
url: "#",
},
{
title: "Starred",
url: "#",
},
{
title: "Settings",
url: "#",
},
],
},
{
title: "Models",
url: "#",
icon: Bot,
items: [
{
title: "Genesis",
url: "#",
},
{
title: "Explorer",
url: "#",
},
{
title: "Quantum",
url: "#",
},
],
},
{
title: "Documentation",
url: "#",
icon: BookOpen,
items: [
{
title: "Introduction",
url: "#",
},
{
title: "Get Started",
url: "#",
},
{
title: "Tutorials",
url: "#",
},
{
title: "Changelog",
url: "#",
},
],
},
{
title: "Settings",
url: "#",
icon: Settings2,
items: [
{
title: "General",
url: "#",
},
{
title: "Team",
url: "#",
},
{
title: "Billing",
url: "#",
},
{
title: "Limits",
url: "#",
},
],
},
],
projects: [
{
name: "Design Engineering",
url: "#",
icon: Frame,
},
{
name: "Sales & Marketing",
url: "#",
icon: PieChart,
},
{
name: "Travel",
url: "#",
icon: Map,
},
],
};
</script>
<template>
<Sidebar v-bind="props">
<SidebarHeader>
<TeamSwitcher :teams="data.teams" />
</SidebarHeader>
<SidebarContent>
<NavMain :items="data.navMain" />
<NavProjects :projects="data.projects" />
</SidebarContent>
<SidebarFooter>
<NavUser :user="data.user" />
</SidebarFooter>
<SidebarRail />
</Sidebar>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import type { LucideIcon } from "lucide-vue-next";
import { ChevronRight } from "lucide-vue-next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/Components/ui/sidebar";
defineProps<{
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}>();
</script>
<template>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<Collapsible
v-for="item in items"
:key="item.title"
as-child
:default-open="item.isActive"
class="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<a :href="subItem.url">
<span>{{ subItem.title }}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</template>
@@ -1,80 +0,0 @@
<script setup lang="ts">
import type { LucideIcon } from "lucide-vue-next";
import { Folder, Forward, MoreHorizontal, Trash2 } from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/Components/ui/sidebar";
defineProps<{
projects: {
name: string;
url: string;
icon: LucideIcon;
}[];
}>();
const { isMobile } = useSidebar();
</script>
<template>
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in projects" :key="item.name">
<SidebarMenuButton as-child>
<a :href="item.url">
<component :is="item.icon" />
<span>{{ item.name }}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuAction show-on-hover>
<MoreHorizontal />
<span class="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-48 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
:align="isMobile ? 'end' : 'start'"
>
<DropdownMenuItem>
<Folder class="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward class="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 class="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton class="text-sidebar-foreground/70">
<MoreHorizontal class="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>
@@ -1,108 +0,0 @@
<script setup lang="ts">
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from "lucide-vue-next";
import { Avatar, AvatarFallback, AvatarImage } from "@/Components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/Components/ui/sidebar";
const props = defineProps<{
user: {
name: string;
email: string;
avatar: string;
};
}>();
const { isMobile } = useSidebar();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span class="truncate text-xs">{{ user.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.name }}</span>
<span class="truncate text-xs">{{ user.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>
@@ -1,92 +0,0 @@
<script setup lang="ts">
import type { Component } from "vue";
import { ChevronsUpDown, Plus } from "lucide-vue-next";
import { ref } from "vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/Components/ui/sidebar";
const props = defineProps<{
teams: {
name: string;
logo: Component;
plan: string;
}[];
}>();
const { isMobile } = useSidebar();
const activeTeam = ref(props.teams[0]);
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
<component :is="activeTeam.logo" class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">
{{ activeTeam.name }}
</span>
<span class="truncate text-xs">{{ activeTeam.plan }}</span>
</div>
<ChevronsUpDown class="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
:side="isMobile ? 'bottom' : 'right'"
:side-offset="4"
>
<DropdownMenuLabel class="text-xs text-muted-foreground">
Teams
</DropdownMenuLabel>
<DropdownMenuItem
v-for="(team, index) in teams"
:key="team.name"
class="gap-2 p-2"
@click="activeTeam = team"
>
<div class="flex size-6 items-center justify-center rounded-sm border">
<component :is="team.logo" class="size-3.5 shrink-0" />
</div>
{{ team.name }}
<DropdownMenuShortcut>{{ index + 1 }}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="gap-2 p-2">
<div
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
>
<Plus class="size-4" />
</div>
<div class="font-medium text-muted-foreground">Add team</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>
@@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { fieldVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(fieldVariants({ orientation }), props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-content"
:class="
cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,23 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { computed } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
errors: { type: Array, required: false },
});
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
if (props.errors.length === 1 && props.errors[0]?.message) {
return props.errors[0].message;
}
return props.errors.some((e) => e?.message) ? props.errors : null;
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul
v-else-if="Array.isArray(content)"
class="ml-4 flex list-disc flex-col gap-1"
>
<li v-for="(error, index) in content" :key="index">
{{ error?.message }}
</li>
</ul>
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,24 @@
<script setup>
import { cn } from "@/lib/utils";
import { Label } from '@/Components/ui/label';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
props.class,
)
"
>
<slot />
</Label>
</template>
@@ -0,0 +1,25 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
variant: { type: String, required: false },
});
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="
cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class,
)
"
>
<slot />
</legend>
</template>
@@ -0,0 +1,30 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/Components/ui/separator';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="
cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)
"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)
"
>
<slot />
</fieldset>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class,
)
"
>
<slot />
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
import { cva } from "class-variance-authority";
export const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";
@@ -36,6 +36,7 @@ const props = defineProps({
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
+4 -6
View File
@@ -251,19 +251,17 @@ function isActive(patterns) {
: 'sticky top-0 h-screen overflow-y-auto',
]"
>
<div
class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white"
>
<div class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar">
<Link
:href="route('dashboard')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<ApplicationMark />
<span
v-if="!sidebarCollapsed"
class="text-sm font-semibold text-gray-900 transition-opacity"
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
>
Admin
Administrator
</span>
</Link>
</div>
+4 -17
View File
@@ -10,19 +10,6 @@ import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue";
import ToastContainer from "@/Components/Toast/ToastContainer.vue";
import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faMobileScreenButton,
faGaugeHigh,
faLayerGroup,
faUserGroup,
faFolderOpen,
faFileImport,
faTableList,
faFileCirclePlus,
faMap,
faGear,
} from "@fortawesome/free-solid-svg-icons";
import { MenuIcon } from "lucide-vue-next";
import { SearchIcon } from "lucide-vue-next";
import { ChevronDownIcon } from "lucide-vue-next";
@@ -310,18 +297,18 @@ function isActive(patterns) {
]"
>
<div
class="h-16 px-4 flex items-center justify-between border-b border-sidebar-border bg-sidebar"
class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar"
>
<Link
:href="route('dashboard')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<ApplicationMark />
<span
v-if="!sidebarCollapsed"
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
>
Teren
Aplikacija
</span>
</Link>
</div>
+3 -3
View File
@@ -149,14 +149,14 @@ const closeSearch = () => (searchOpen.value = false);
>
<Link
:href="route('phone.index')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<ApplicationMark />
<span
v-if="showLabels"
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
>
Teren
Mobitel
</span>
</Link>
</div>
+166 -190
View File
@@ -1,8 +1,18 @@
<script setup>
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { Card, CardContent } from "@/Components/ui/card";
import { Separator } from "@/Components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import axios from "axios";
import { debounce } from "lodash";
import { SearchIcon } from "@/Utilities/Icons";
import { SearchIcon, XIcon } from "lucide-vue-next";
import { onMounted, onUnmounted, ref, watch } from "vue";
import { Link } from "@inertiajs/vue3";
@@ -55,203 +65,169 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script>
<template>
<teleport to="body">
<transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50">
<div
class="absolute inset-0 bg-gradient-to-br from-slate-900/60 to-slate-800/60 backdrop-blur-sm"
@click="isOpen = false"
></div>
<div
class="absolute inset-0 flex items-start justify-center p-4 pt-20 sm:pt-28"
@click.self="isOpen = false"
>
<div
class="w-full max-w-3xl rounded-2xl border border-white/10 bg-white/80 backdrop-blur-xl shadow-2xl ring-1 ring-black/5 overflow-hidden"
role="dialog"
aria-modal="true"
<Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
<DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
<div class="p-4 border-b" ref="inputWrap">
<div class="relative">
<SearchIcon
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
/>
<Input
v-model="query"
placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
class="w-full pl-10 pr-16"
/>
<button
v-if="query"
@click="query = ''"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-accent"
>
<XIcon class="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
<div class="max-h-[65vh] overflow-y-auto">
<div
v-if="!query"
class="p-8 text-sm text-muted-foreground text-center space-y-2"
>
<p>Začni tipkati za iskanje.</p>
<p class="text-xs">
Namig: uporabi <Badge variant="secondary" class="font-mono">Ctrl</Badge> +
<Badge variant="secondary" class="font-mono">K</Badge>
</p>
</div>
<div v-else class="space-y-4 p-4">
<!-- Clients Results -->
<div v-if="result.clients.length">
<div
class="p-4 border-b border-slate-200/60"
ref="inputWrap"
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
>
<div class="relative">
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
<SearchIcon />
</div>
<Input
v-model="query"
placeholder="Išči po naročnikih ali primerih (Ctrl+K za zapiranje)"
class="w-full pl-10 pr-16 rounded-xl"
/>
<button
v-if="query"
@click="query = ''"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-700"
>
ESC
</button>
</div>
</div>
<span>Naročniki</span>
<Badge variant="secondary">{{ result.clients.length }}</Badge>
</div>
<div
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300"
>
<div
v-if="!query"
class="p-8 text-sm text-slate-500 text-center space-y-2"
<div class="space-y-1">
<Link
v-for="client in result.clients"
:key="client.client_uuid"
:href="route('client.show', { uuid: client.client_uuid })"
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-accent transition"
@click="isOpen = false"
>
<p>Začni tipkati za iskanje.</p>
<p class="text-xs">
Namig: uporabi
<kbd
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
>Ctrl</kbd
>
+
<kbd
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
>K</kbd
>
</p>
</div>
<div v-else class="divide-y divide-slate-200/70">
<div v-if="result.clients.length" class="py-3">
<div
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
>
<span>Naročniki</span>
<span
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.clients.length }}</span
>
</div>
<ul role="list" class="px-2 space-y-1">
<li v-for="client in result.clients" :key="client.client_uuid">
<Link
:href="route('client.show', { uuid: client.client_uuid })"
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-indigo-50/70 transition"
@click="isOpen = false"
>
<span
class="shrink-0 w-6 h-6 rounded bg-indigo-100 text-indigo-600 flex items-center justify-center text-[11px] font-semibold group-hover:bg-indigo-200"
>C</span
>
<span
class="text-slate-700 group-hover:text-slate-900"
>{{ client.full_name }}</span
>
</Link>
</li>
</ul>
</div>
<div v-if="result.client_cases.length" class="py-3">
<div
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
>
<span>Primeri</span>
<span
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.client_cases.length }}</span
>
</div>
<ul role="list" class="px-2 space-y-1">
<li
v-for="clientcase in result.client_cases"
:key="clientcase.case_uuid"
class="rounded-xl border border-slate-200/70 bg-white/70 px-4 py-3 shadow-sm hover:shadow-md transition flex flex-col gap-1"
>
<div class="flex items-center gap-2">
<Link
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
})
"
class="text-left font-medium hover:underline leading-tight text-slate-800"
@click="isOpen = false"
>
{{ clientcase.full_name }}
</Link>
<template v-if="clientcase.contract_reference">
<span
class="font-mono text-[11px] tracking-tight text-indigo-600 bg-indigo-50 border border-indigo-200 rounded px-1.5 py-0.5 whitespace-nowrap shadow-sm"
>
{{ clientcase.contract_reference }}
</span>
</template>
</div>
<div
v-if="
clientcase.contract_segments &&
clientcase.contract_segments.length
"
class="flex flex-wrap gap-1 mt-1"
>
<Link
v-for="seg in clientcase.contract_segments"
:key="seg.id || seg.name || seg"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
class="group/seg text-[10px] uppercase tracking-wide bg-gradient-to-br from-purple-50 to-purple-100 text-purple-700 border border-purple-200 px-1.5 py-0.5 rounded hover:from-purple-100 hover:to-purple-200 hover:border-purple-300 transition"
@click="isOpen = false"
>
{{ seg.name || seg }}
</Link>
</div>
<div
v-else-if="
clientcase.case_segments && clientcase.case_segments.length
"
class="flex flex-wrap gap-1 mt-1"
>
<Link
v-for="seg in clientcase.case_segments"
:key="seg.id || seg.name"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
class="text-[10px] uppercase tracking-wide bg-slate-100 text-slate-600 border border-slate-200 px-1.5 py-0.5 rounded hover:bg-slate-200 hover:text-slate-700 transition"
@click="isOpen = false"
>
{{ seg.name }}
</Link>
</div>
</li>
</ul>
</div>
<div
v-if="!result.clients.length && !result.client_cases.length"
class="p-8 text-center text-sm text-slate-500"
<Badge
variant="outline"
class="shrink-0 w-6 h-6 flex items-center justify-center"
>C</Badge
>
Ni rezultatov.
</div>
</div>
<span class="font-medium">{{ client.full_name }}</span>
</Link>
</div>
</div>
<Separator v-if="result.clients.length && result.client_cases.length" />
<!-- Client Cases Results -->
<div v-if="result.client_cases.length">
<div
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
>
<span>Primeri</span>
<Badge variant="secondary">{{ result.client_cases.length }}</Badge>
</div>
<div class="space-y-2">
<Card
v-for="clientcase in result.client_cases"
:key="clientcase.case_uuid"
class="hover:shadow-md transition p-0"
>
<CardContent class="p-3 space-y-2">
<div class="space-y-1">
<Link
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
})
"
class="text-sm font-medium hover:underline block"
@click="isOpen = false"
>
{{ clientcase.full_name }}
</Link>
<div
v-if="clientcase.client_full_name"
class="text-xs text-muted-foreground"
>
Naročnik: {{ clientcase.client_full_name }}
</div>
</div>
<div
v-if="clientcase.contract_reference"
class="flex items-center gap-1"
>
<Badge variant="outline" class="font-mono text-xs">
{{ clientcase.contract_reference }}
</Badge>
</div>
<div
v-if="
clientcase.contract_segments && clientcase.contract_segments.length
"
class="flex flex-wrap gap-1"
>
<Link
v-for="seg in clientcase.contract_segments"
:key="seg.id || seg.name || seg"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
@click="isOpen = false"
>
<Badge variant="secondary" class="text-xs uppercase">
{{ seg.name || seg }}
</Badge>
</Link>
</div>
<div
v-else-if="
clientcase.case_segments && clientcase.case_segments.length
"
class="flex flex-wrap gap-1"
>
<Link
v-for="seg in clientcase.case_segments"
:key="seg.id || seg.name"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
@click="isOpen = false"
>
<Badge variant="outline" class="text-xs uppercase">
{{ seg.name }}
</Badge>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
<!-- No Results -->
<div
v-if="!result.clients.length && !result.client_cases.length"
class="p-8 text-center text-sm text-muted-foreground"
>
Ni rezultatov.
</div>
</div>
</div>
</transition>
</teleport>
</DialogContent>
</Dialog>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -1,12 +1,12 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { usePage, Link, router } from "@inertiajs/vue3";
import Dropdown from "@/Components/Dropdown.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faBell } from "@fortawesome/free-solid-svg-icons";
import { BellIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { ScrollArea } from "@/Components/ui/scroll-area";
import { Separator } from "@/Components/ui/separator";
const page = usePage();
const due = computed(
@@ -83,12 +83,8 @@ function markRead(item) {
</script>
<template>
<Dropdown
align="right"
width="72"
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
>
<template #trigger>
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="default" class="relative">
<BellIcon />
@@ -100,32 +96,30 @@ function markRead(item) {
{{ count }}
</Badge>
</Button>
</template>
</PopoverTrigger>
<template #content>
<div
class="px-3 py-2 text-xs text-gray-400 border-b sticky top-0 bg-white z-10 flex items-center justify-between"
>
<span>Zapadejo danes</span>
<PopoverContent align="end" class="w-96 p-0">
<div class="px-4 py-3 flex items-center justify-between border-b">
<span class="text-sm font-medium">Zapadejo danes</span>
<Link
:href="route('notifications.unread')"
class="text-indigo-600 hover:text-indigo-700"
class="text-sm text-primary hover:underline"
>Vsa obvestila</Link
>
</div>
<!-- Scrollable content area with max height -->
<div class="max-h-80 overflow-auto">
<div v-if="!count" class="px-3 py-3 text-sm text-gray-500">
Ni zapadlih aktivnosti danes.
<ScrollArea class="h-72">
<div v-if="!count" class="px-4 py-8 text-center">
<p class="text-sm text-muted-foreground">Ni zapadlih aktivnosti danes.</p>
</div>
<ul v-else class="divide-y">
<li
<div v-else class="divide-y">
<div
v-for="item in items"
:key="item.id"
class="px-3 py-2 text-sm flex items-start gap-2"
class="px-4 py-3 flex items-start gap-3 hover:bg-accent/50 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-800 truncate">
<div class="flex-1 min-w-0 space-y-1">
<div class="font-medium truncate">
<template v-if="item.contract?.uuid">
Pogodba:
<Link
@@ -135,7 +129,7 @@ function markRead(item) {
client_case: item.contract.client_case.uuid,
})
"
class="text-indigo-600 hover:text-indigo-700 hover:underline"
class="text-primary hover:underline"
>
{{ item.contract?.reference || "—" }}
</Link>
@@ -148,7 +142,7 @@ function markRead(item) {
:href="
route('clientCase.show', { client_case: item.client_case.uuid })
"
class="text-indigo-600 hover:text-indigo-700 hover:underline"
class="text-primary hover:underline"
>
{{ item.client_case?.person?.full_name || "—" }}
</Link>
@@ -157,37 +151,38 @@ function markRead(item) {
</div>
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
<div
class="text-xs text-gray-500 truncate"
class="text-xs text-muted-foreground truncate"
v-if="item.contract?.client?.person?.full_name"
>
Partner: {{ item.contract.client.person.full_name }}
</div>
<div
class="text-xs text-gray-500 truncate"
class="text-xs text-muted-foreground truncate"
v-else-if="item.client_case?.client?.person?.full_name"
>
Partner: {{ item.client_case.client.person.full_name }}
</div>
<div class="text-gray-600 truncate" v-if="item.contract">
<div class="text-sm truncate" v-if="item.contract">
{{ fmtEUR(item.contract?.account?.balance_amount) }}
</div>
</div>
<div class="flex flex-col items-end gap-1">
<div class="text-xs text-gray-500 whitespace-nowrap">
<div class="flex flex-col items-end gap-1.5 shrink-0">
<div class="text-xs text-muted-foreground whitespace-nowrap">
{{ fmtDate(item.due_date) }}
</div>
<button
type="button"
class="text-[11px] text-gray-400 hover:text-gray-600"
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click.stop="markRead(item)"
title="Skrij obvestilo"
>
Skrij
</button>
</Button>
</div>
</li>
</ul>
</div>
</template>
</Dropdown>
</div>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,810 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Textarea } from "@/Components/ui/textarea";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import {
PackageIcon,
PhoneIcon,
UsersIcon,
SearchIcon,
SaveIcon,
ArrowLeftIcon,
FilterIcon,
CalendarIcon,
CheckCircle2Icon,
XCircleIcon,
BadgeCheckIcon,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
import { upperFirst } from "lodash";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppRangeDatePicker from "@/Components/app/ui/AppRangeDatePicker.vue";
const props = defineProps({
profiles: { type: Array, default: () => [] },
senders: { type: Array, default: () => [] },
templates: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
clients: { type: Array, default: () => [] },
});
const creatingFromContracts = ref(false);
const createMode = ref("numbers"); // 'numbers' | 'contracts'
const form = useForm({
type: "sms",
name: "",
description: "",
profile_id: null,
sender_id: null,
template_id: null,
delivery_report: false,
body: "",
numbers: "", // one per line
});
const filteredSenders = computed(() => {
if (!form.profile_id) return props.senders;
return props.senders.filter((s) => s.profile_id === form.profile_id);
});
function onTemplateChange() {
const template = props.templates.find((t) => t.id === form.template_id);
if (template?.content) {
form.body = template.content;
} else {
form.body = "";
}
}
function submitCreate() {
const lines = (form.numbers || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
if (!lines.length) return;
if (!form.profile_id && !form.template_id) {
alert("Izberi SMS profil ali predlogo.");
return;
}
if (!form.template_id && !form.body) {
alert("Vnesi vsebino sporočila ali izberi predlogo.");
return;
}
const payload = {
type: "sms",
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
description: form.description || "",
items: lines.map((number) => ({
number,
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: form.body && form.body.trim() ? form.body.trim() : null,
},
})),
};
router.post(route("admin.packages.store"), payload, {
onSuccess: () => {
router.visit(route("admin.packages.index"));
},
});
}
// Contracts mode state & actions
const contracts = ref({
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
});
const segmentId = ref(null);
const search = ref("");
const clientId = ref(null);
const startDateRange = ref({ start: null, end: null });
const promiseDateRange = ref({ start: null, end: null });
const onlyMobile = ref(false);
const onlyValidated = ref(false);
const loadingContracts = ref(false);
// Transform clients for AppCombobox
const clientItems = computed(() =>
props.clients.map((c) => ({
value: c.id,
label: c.name,
}))
);
const selectedContractIds = ref(new Set());
const perPage = ref(25);
// DataTable columns definition
const contractColumns = [
{ accessorKey: "reference", header: "Pogodba" },
{
id: "person",
accessorFn: (row) => row.person?.full_name || "—",
header: "Primer",
},
{
id: "client",
accessorFn: (row) => row.client?.name || "—",
header: "Stranka",
},
{ accessorKey: "start_date", header: "Datum začetka" },
{ accessorKey: "promise_date", header: "Zadnja obljuba" },
{
id: "selected_phone",
accessorFn: (row) => row.selected_phone?.number || "—",
header: "Izbrana številka",
},
{
id: "segment",
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
header: "Segment",
},
{ accessorKey: "no_phone_reason", header: "Opomba" },
];
function onSelectionChange(selectedKeys) {
// selectedKeys are indices from the table
const newSelection = new Set();
selectedKeys.forEach((key) => {
const index = parseInt(key);
if (contracts.value.data[index]) {
newSelection.add(contracts.value.data[index].id);
}
});
selectedContractIds.value = newSelection;
}
async function loadContracts(url = null) {
loadingContracts.value = true;
try {
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end)
params.append("start_date_to", startDateRange.value.end);
if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
// Wait for next tick before updating to avoid Vue reconciliation issues
await nextTick();
contracts.value = {
data: json.data || [],
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
} finally {
loadingContracts.value = false;
}
}
function toggleSelectContract(id) {
const s = selectedContractIds.value;
if (s.has(id)) {
s.delete(id);
} else {
s.add(id);
}
selectedContractIds.value = new Set(Array.from(s));
}
// Get row selection state for DataTable
const rowSelection = computed(() => {
const selection = {};
contracts.value.data.forEach((contract, index) => {
if (selectedContractIds.value.has(contract.id)) {
selection[index.toString()] = true;
}
});
return selection;
});
// Computed key to force DataTable re-render on page change
const tableKey = computed(() => {
return `contracts-${contracts.value.meta.current_page}-${contracts.value.data.length}`;
});
function clearSelection() {
selectedContractIds.value = new Set();
}
function goToPage(page) {
if (page < 1 || page > contracts.value.meta.last_page) return;
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end) params.append("start_date_to", startDateRange.value.end);
if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
params.append("page", page);
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
loadContracts(url);
}
function resetFilters() {
segmentId.value = null;
clientId.value = null;
search.value = "";
startDateRange.value = { start: null, end: null };
promiseDateRange.value = { start: null, end: null };
onlyMobile.value = false;
onlyValidated.value = false;
contracts.value = {
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
}
function submitCreateFromContracts() {
const ids = Array.from(selectedContractIds.value);
if (!ids.length) return;
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
return;
}
const payload = {
type: "sms",
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
description: form.description || "",
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: form.body && form.body.trim() ? form.body.trim() : null,
},
contract_ids: ids,
};
creatingFromContracts.value = true;
router.post(route("admin.packages.store-from-contracts"), payload, {
onSuccess: () => {
router.visit(route("admin.packages.index"));
},
onError: (errors) => {
const first = errors && Object.values(errors)[0];
if (first) {
alert(String(first));
}
},
onFinish: () => {
creatingFromContracts.value = false;
},
});
}
const numbersCount = computed(() => {
return (form.numbers || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean).length;
});
</script>
<template>
<AdminLayout title="Ustvari SMS paket">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<Link :href="route('admin.packages.index')">
<Button variant="ghost" size="sm">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Button>
</Link>
</div>
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<PackageIcon class="h-6 w-6 text-primary" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Ustvari SMS paket</h1>
<p class="text-sm text-muted-foreground">Pošlji SMS sporočila v paketu</p>
</div>
</div>
</div>
<!-- Main Content -->
<Tabs v-model="createMode" class="w-full">
<TabsList class="flex flex-row justify-baseline py-4">
<TabsTrigger value="numbers" class="p-3">
<span class="flex gap-2 items-center align-middle justify-center">
<PhoneIcon class="h-5 w-5" />Vnos številk
</span>
</TabsTrigger>
<TabsTrigger value="contracts" class="p-3">
<span class="flex gap-2 items-center align-middle justify-center">
<UsersIcon class="h-5 w-5" />Iz pogodb (segment)
</span>
</TabsTrigger>
</TabsList>
<!-- Package Details Card -->
<Card class="mb-6">
<CardHeader>
<CardTitle>Podatki o paketu</CardTitle>
<CardDescription>Osnovne informacije in SMS nastavitve</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Basic Info -->
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="name">Ime paketa</Label>
<Input
id="name"
v-model="form.name"
placeholder="Npr. SMS kampanja december 2024"
/>
</div>
<div class="space-y-2">
<Label for="description">Opis</Label>
<Input
id="description"
v-model="form.description"
placeholder="Neobvezen opis paketa"
/>
</div>
</div>
<Separator />
<!-- SMS Configuration -->
<div>
<h3 class="text-sm font-semibold mb-4">SMS nastavitve</h3>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>SMS profil</Label>
<Select v-model="form.profile_id">
<SelectTrigger>
<SelectValue placeholder="Izberi profil" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Pošiljatelj</Label>
<Select v-model="form.sender_id">
<SelectTrigger>
<SelectValue placeholder="Izberi pošiljatelja" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
{{ s.sname }}
<span v-if="s.phone_number" class="text-muted-foreground">
({{ s.phone_number }})
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
<SelectTrigger>
<SelectValue placeholder="Izberi predlogo" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="space-y-2">
<Label for="body">Vsebina sporočila</Label>
<Textarea
id="body"
v-model="form.body"
rows="4"
placeholder="Vsebina SMS sporočila..."
class="font-mono text-sm"
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Checkbox
:model-value="form.delivery_report"
@update:model-value="(val) => (form.delivery_report = val)"
id="delivery-report"
:disabled="true"
/>
<Label for="delivery-report" class="cursor-pointer text-sm">
Zahtevaj delivery report
</Label>
</div>
<p class="text-xs text-muted-foreground">
{{ form.body?.length || 0 }} znakov
</p>
</div>
</div>
</CardContent>
</Card>
<!-- Numbers Mode -->
<TabsContent value="numbers">
<Card>
<CardHeader>
<CardTitle>Telefonske številke</CardTitle>
<CardDescription
>Vnesi telefonske številke prejemnikov (ena na vrstico)</CardDescription
>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Textarea
v-model="form.numbers"
rows="10"
placeholder="+38640123456&#10;+38640123457&#10;+38641234567"
class="font-mono text-sm"
/>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
<strong>{{ numbersCount }}</strong>
{{
numbersCount === 1
? "številka"
: numbersCount < 5
? "številke"
: "številk"
}}
</p>
<Badge v-if="numbersCount > 0" variant="secondary">
<CheckCircle2Icon class="h-3 w-3 mr-1" />
Pripravljeno
</Badge>
</div>
</div>
<div class="flex justify-end gap-2">
<Button
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
</Button>
<Button
@click="submitCreate"
:disabled="numbersCount === 0 || (!form.profile_id && !form.template_id)"
>
<SaveIcon class="h-4 w-4 mr-2" />
Ustvari paket
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<!-- Contracts Mode -->
<TabsContent value="contracts">
<Card class="mb-6">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Filtri za pogodbe</CardTitle>
<CardDescription
>Najdi prejemnike glede na pogodbe in segmente</CardDescription
>
</div>
<Badge variant="outline" class="text-xs">
<FilterIcon class="h-3 w-3 mr-1" />
Napredno iskanje
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- Basic filters -->
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>Segment</Label>
<Select v-model="segmentId" @update:model-value="loadContracts()">
<SelectTrigger>
<SelectValue placeholder="Vsi segmenti" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vsi segmenti</SelectItem>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Stranka</Label>
<AppCombobox
v-model="clientId"
:items="clientItems"
placeholder="Vse stranke"
search-placeholder="Išči stranko..."
empty-text="Stranka ni najdena."
button-class="w-full"
@update:model-value="loadContracts()"
/>
</div>
<div class="space-y-2">
<Label>Iskanje po referenci</Label>
<Input
v-model="search"
@keyup.enter="loadContracts()"
placeholder="Vnesi referenco..."
/>
</div>
</div>
<Separator />
<!-- Date filters -->
<div>
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
<CalendarIcon class="h-4 w-4" />
Datumski filtri
</h4>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
<AppRangeDatePicker
v-model="startDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
<AppRangeDatePicker
v-model="promiseDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
</div>
</div>
<Separator />
<!-- Phone filters -->
<div>
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
<div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2">
<Checkbox
:model-value="onlyMobile"
@update:model-value="
(val) => {
onlyMobile = val;
}
"
id="only-mobile"
/>
<Label for="only-mobile" class="cursor-pointer text-sm">
Samo mobilne številke
</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
:model-value="onlyValidated"
@update:model-value="
(val) => {
onlyValidated = val;
}
"
id="only-validated"
/>
<Label for="only-validated" class="cursor-pointer text-sm">
Samo potrjene številke
</Label>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<Button @click="loadContracts()">
<SearchIcon class="h-4 w-4" />
Išči pogodbe
</Button>
<Button @click="resetFilters" variant="outline">
<XCircleIcon class="h-4 w-4" />
Počisti filtre
</Button>
</div>
</CardContent>
</Card>
<!-- Results -->
<Card v-if="contracts.data.length > 0 || loadingContracts">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
<CardDescription v-if="contracts.meta.total > 0">
Najdeno {{ contracts.meta.total }}
{{
contracts.meta.total === 1
? "pogodba"
: contracts.meta.total < 5
? "pogodbe"
: "pogodb"
}}
</CardDescription>
</div>
<!-- Create Button -->
<div class="flex justify-end gap-2" v-if="selectedContractIds.size > 0">
<Badge
v-if="selectedContractIds.size > 0"
variant="secondary"
class="text-sm"
>
<CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
</Button>
<Button
@click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
>
<SaveIcon class="h-4 w-4" />
Ustvari paket ({{ selectedContractIds.size }}
{{
selectedContractIds.size === 1
? "pogodba"
: selectedContractIds.size < 5
? "pogodbe"
: "pogodb"
}})
</Button>
</div>
</div>
</CardHeader>
<CardContent class="p-0">
<DataTableNew2
v-if="!loadingContracts"
:key="tableKey"
:columns="contractColumns"
:data="contracts.data"
:enableRowSelection="true"
:rowSelection="rowSelection"
:showPagination="true"
:page-size="50"
:page-size-options="[10, 15, 25, 50, 100]"
:showToolbar="false"
@selection:change="onSelectionChange"
>
<template #cell-reference="{ row }">
<div v-if="row.original" class="space-y-1">
<p class="font-medium">{{ row.original.reference || "" }}</p>
<p class="text-xs text-muted-foreground font-mono">
#{{ row.original.id }}
</p>
</div>
</template>
<template #cell-person="{ row }">
<span v-if="row.original" class="text-xs">{{
row.original.person?.full_name || ""
}}</span>
</template>
<template #cell-client="{ row }">
<span v-if="row.original" class="text-xs">{{
row.original.client?.name || ""
}}</span>
</template>
<template #cell-start_date="{ row }">
{{ fmtDateDMY(row.start_date) || "" }}
</template>
<template #cell-promise_date="{ row }">
{{ fmtDateDMY(row.promise_date) || "" }}
</template>
<template #cell-selected_phone="{ row }">
<div v-if="row.selected_phone" class="space-y-1">
<div class="flex flex-col items-center gap-1">
<span>{{ row.selected_phone.number }}</span>
<span
><Badge
v-if="row.selected_phone.validated"
variant="secondary"
class="text-xs"
>
<BadgeCheckIcon />
Potrjena
</Badge>
<Badge
v-else
variant="destructive"
class="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums text-accent"
>
Nepotrjena
</Badge></span
>
</div>
</div>
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
</template>
<template #cell-no_phone_reason="{ row }">
<span v-if="row.original" class="text-xs text-muted-foreground">{{
row.original.no_phone_reason || ""
}}</span>
</template>
</DataTableNew2>
<div v-else class="text-center text-muted-foreground py-24">Nalaganje...</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</AdminLayout>
</template>
+63 -709
View File
@@ -1,57 +1,35 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed } from "vue";
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Card, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Textarea } from "@/Components/ui/textarea";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Separator } from "@/Components/ui/separator";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import Pagination from "@/Components/Pagination.vue";
import {
PackageIcon,
PlusIcon,
XIcon,
SearchIcon,
Trash2Icon,
EyeIcon,
} from "lucide-vue-next";
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
packages: { type: Object, required: true },
profiles: { type: Array, default: () => [] },
senders: { type: Array, default: () => [] },
templates: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
clients: { type: Array, default: () => [] },
});
const deletingId = ref(null);
const creatingFromContracts = ref(false);
const packageToDelete = ref(null);
const showDeleteDialog = ref(false);
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "uuid", header: "UUID" },
{ accessorKey: "name", header: "Ime" },
{ accessorKey: "type", header: "Tip" },
{ accessorKey: "status", header: "Status" },
@@ -73,242 +51,23 @@ function goShow(id) {
router.visit(route("admin.packages.show", id));
}
const showCreate = ref(false);
const createMode = ref("numbers"); // 'numbers' | 'contracts'
const form = useForm({
type: "sms",
name: "",
description: "",
profile_id: null,
sender_id: null,
template_id: null,
delivery_report: false,
body: "",
numbers: "", // one per line
});
const filteredSenders = computed(() => {
if (!form.profile_id) return props.senders;
return props.senders.filter((s) => s.profile_id === form.profile_id);
});
function onTemplateChange() {
const template = props.templates.find((t) => t.id === form.template_id);
if (template?.content) {
form.body = template.content;
} else {
form.body = "";
}
}
function submitCreate() {
const lines = (form.numbers || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
if (!lines.length) return;
if (!form.profile_id && !form.template_id) {
// require profile if no template/default profile resolution available
alert("Izberi SMS profil ali predlogo.");
return;
}
if (!form.template_id && !form.body) {
alert("Vnesi vsebino sporočila ali izberi predlogo.");
return;
}
const payload = {
type: "sms",
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
description: form.description || "",
items: lines.map((number) => ({
number,
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: form.body && form.body.trim() ? form.body.trim() : null,
},
})),
};
router.post(route("admin.packages.store"), payload, {
onSuccess: () => {
form.reset();
showCreate.value = false;
router.reload({ only: ["packages"] });
},
});
}
// Contracts mode state & actions
const contracts = ref({
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
});
const segmentId = ref(null);
const search = ref("");
const clientId = ref(null);
const startDateFrom = ref("");
const startDateTo = ref("");
const promiseDateFrom = ref("");
const promiseDateTo = ref("");
const onlyMobile = ref(false);
const onlyValidated = ref(false);
const loadingContracts = ref(false);
const selectedContractIds = ref(new Set());
const perPage = ref(25);
async function loadContracts(url = null) {
loadingContracts.value = true;
try {
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const res = await fetch(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
const json = await res.json();
contracts.value = {
data: json.data || [],
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
} finally {
loadingContracts.value = false;
}
}
function toggleSelectContract(id) {
const s = selectedContractIds.value;
if (s.has(id)) {
s.delete(id);
} else {
s.add(id);
}
// force reactivity
selectedContractIds.value = new Set(Array.from(s));
}
function clearSelection() {
selectedContractIds.value = new Set();
}
function deletePackage(pkg) {
function openDeleteDialog(pkg) {
if (!pkg || pkg.status !== "draft") return;
if (!confirm(`Izbrišem paket #${pkg.id}?`)) return;
deletingId.value = pkg.id;
router.delete(route("admin.packages.destroy", pkg.id), {
packageToDelete.value = pkg;
showDeleteDialog.value = true;
}
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
onFinish: () => {
deletingId.value = null;
},
});
}
function toggleSelectAll() {
const currentPageIds = contracts.value.data.map((c) => c.id);
const allSelected = currentPageIds.every((id) => selectedContractIds.value.has(id));
if (allSelected) {
// Deselect all on current page
currentPageIds.forEach((id) => selectedContractIds.value.delete(id));
} else {
// Select all on current page
currentPageIds.forEach((id) => selectedContractIds.value.add(id));
}
// Force reactivity
selectedContractIds.value = new Set(Array.from(selectedContractIds.value));
}
const allCurrentPageSelected = computed(() => {
if (!contracts.value.data.length) return false;
return contracts.value.data.every((c) => selectedContractIds.value.has(c.id));
});
const someCurrentPageSelected = computed(() => {
if (!contracts.value.data.length) return false;
return (
contracts.value.data.some((c) => selectedContractIds.value.has(c.id)) &&
!allCurrentPageSelected.value
);
});
function goContractsPage(delta) {
const { current_page } = contracts.value.meta;
const nextPage = current_page + delta;
if (nextPage < 1 || nextPage > contracts.value.meta.last_page) return;
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
params.append("page", nextPage);
const base = `${route("admin.packages.contracts")}?${params.toString()}`;
loadContracts(base);
}
function submitCreateFromContracts() {
const ids = Array.from(selectedContractIds.value);
if (!ids.length) return;
// Optional quick client-side sanity: if all selected are from current page and none have phones, warn early.
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
return;
}
const payload = {
type: "sms",
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
description: form.description || "",
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: form.body && form.body.trim() ? form.body.trim() : null,
},
contract_ids: ids,
};
creatingFromContracts.value = true;
router.post(route("admin.packages.store-from-contracts"), payload, {
onSuccess: () => {
clearSelection();
showCreate.value = false;
router.reload({ only: ["packages"] });
},
onError: (errors) => {
// Show the first validation error if present
const first = errors && Object.values(errors)[0];
if (first) {
alert(String(first));
}
},
onFinish: () => {
creatingFromContracts.value = false;
showDeleteDialog.value = false;
packageToDelete.value = null;
},
});
}
@@ -323,444 +82,16 @@ function submitCreateFromContracts() {
<PackageIcon class="h-5 w-5 text-muted-foreground" />
<CardTitle>SMS paketi</CardTitle>
</div>
<Button
@click="showCreate = !showCreate"
:variant="showCreate ? 'outline' : 'default'"
>
<component :is="showCreate ? XIcon : PlusIcon" class="h-4 w-4 mr-2" />
{{ showCreate ? "Zapri" : "Nov paket" }}
</Button>
<Link :href="route('admin.packages.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
</Button>
</Link>
</div>
</CardHeader>
</Card>
<Card v-if="showCreate" class="mb-6">
<CardContent class="pt-6">
<div class="mb-4 flex items-center gap-4">
<Label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="numbers"
v-model="createMode"
class="rounded-full"
/>
Vnos številk
</Label>
<Label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="contracts"
v-model="createMode"
class="rounded-full"
/>
Iz pogodb (segment)
</Label>
</div>
<div class="grid sm:grid-cols-3 gap-4">
<div class="space-y-2">
<Label>Profil</Label>
<Select v-model="form.profile_id">
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">{{
p.name
}}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Pošiljatelj</Label>
<Select v-model="form.sender_id">
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
{{ s.sname }} <span v-if="s.phone_number">({{ s.phone_number }})</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">{{
t.name
}}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="sm:col-span-3 space-y-2">
<Label>Vsebina (če ni predloge)</Label>
<Textarea v-model="form.body" rows="3" placeholder="Sporočilo..." />
<div class="flex items-center gap-2">
<Checkbox
:checked="form.delivery_report"
@update:checked="(val) => (form.delivery_report = val)"
id="delivery-report"
/>
<Label for="delivery-report" class="cursor-pointer"
>Zahtevaj delivery report</Label
>
</div>
</div>
</div>
<!-- Numbers mode -->
<template v-if="createMode === 'numbers'">
<div class="sm:col-span-3 space-y-2">
<Label>Telefonske številke (ena na vrstico)</Label>
<Textarea
v-model="form.numbers"
rows="4"
placeholder="+38640123456&#10;+38640123457"
/>
</div>
<div class="sm:col-span-3 flex items-center justify-end gap-2">
<Button @click="submitCreate"> Ustvari paket </Button>
</div>
</template>
<!-- Contracts mode -->
<template v-else>
<div class="sm:col-span-3 space-y-4">
<Separator />
<!-- Basic filters -->
<div class="grid sm:grid-cols-3 gap-4">
<div class="space-y-2">
<Label>Segment</Label>
<Select v-model="segmentId" @update:model-value="loadContracts()">
<SelectTrigger>
<SelectValue placeholder="Vsi segmenti" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vsi segmenti</SelectItem>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">{{
s.name
}}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Stranka</Label>
<Select v-model="clientId" @update:model-value="loadContracts()">
<SelectTrigger>
<SelectValue placeholder="Vse stranke" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vse stranke</SelectItem>
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">{{
c.name
}}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Iskanje po referenci</Label>
<Input
v-model="search"
@keyup.enter="loadContracts()"
type="text"
placeholder="Vnesi referenco..."
/>
</div>
</div>
<!-- Date range filters -->
<Separator />
<div>
<h4 class="text-sm font-semibold mb-3">Datumski filtri</h4>
<div class="space-y-4">
<div>
<div class="text-sm font-medium text-muted-foreground mb-2">
Datum začetka pogodbe
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-2">
<Label>Od</Label>
<Input
v-model="startDateFrom"
@change="loadContracts()"
type="date"
/>
</div>
<div class="space-y-2">
<Label>Do</Label>
<Input
v-model="startDateTo"
@change="loadContracts()"
type="date"
/>
</div>
</div>
</div>
<div>
<div class="text-sm font-medium text-muted-foreground mb-2">
Datum obljube plačila
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-2">
<Label>Od</Label>
<Input
v-model="promiseDateFrom"
@change="loadContracts()"
type="date"
/>
</div>
<div class="space-y-2">
<Label>Do</Label>
<Input
v-model="promiseDateTo"
@change="loadContracts()"
type="date"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Phone filters -->
<Separator />
<div>
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Checkbox
:checked="onlyMobile"
@update:checked="
(val) => {
onlyMobile = val;
loadContracts();
}
"
id="only-mobile"
/>
<Label for="only-mobile" class="cursor-pointer"
>Samo mobilne številke</Label
>
</div>
<div class="flex items-center gap-2">
<Checkbox
:checked="onlyValidated"
@update:checked="
(val) => {
onlyValidated = val;
loadContracts();
}
"
id="only-validated"
/>
<Label for="only-validated" class="cursor-pointer"
>Samo potrjene številke</Label
>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<Button @click="loadContracts()">
<SearchIcon class="h-4 w-4 mr-2" />
Išči pogodbe
</Button>
<Button
@click="
segmentId = null;
clientId = null;
search = '';
startDateFrom = '';
startDateTo = '';
promiseDateFrom = '';
promiseDateTo = '';
onlyMobile = false;
onlyValidated = false;
contracts.value = {
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
"
variant="outline"
>
Počisti filtre
</Button>
</div>
</div>
<!-- Results table -->
<div class="sm:col-span-3">
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<input
type="checkbox"
:checked="allCurrentPageSelected"
:indeterminate="someCurrentPageSelected"
@change="toggleSelectAll"
:disabled="!contracts.data.length"
class="rounded"
title="Izberi vse na tej strani"
/>
</TableHead>
<TableHead>Pogodba</TableHead>
<TableHead>Primer</TableHead>
<TableHead>Stranka</TableHead>
<TableHead>Datum začetka</TableHead>
<TableHead>Zadnja obljuba</TableHead>
<TableHead>Izbrana številka</TableHead>
<TableHead>Opomba</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="!loadingContracts">
<TableRow v-for="c in contracts.data" :key="c.id">
<TableCell>
<input
type="checkbox"
:checked="selectedContractIds.has(c.id)"
@change="toggleSelectContract(c.id)"
class="rounded"
/>
</TableCell>
<TableCell>
<div class="font-mono text-xs text-muted-foreground">
{{ c.uuid }}
</div>
<a
v-if="c.case?.uuid"
:href="route('clientCase.show', c.case.uuid)"
target="_blank"
rel="noopener noreferrer"
class="text-xs font-medium text-primary hover:underline"
>
{{ c.reference }}
</a>
<div v-else class="text-xs font-medium">{{ c.reference }}</div>
</TableCell>
<TableCell class="text-xs">
{{ c.person?.full_name || "—" }}
</TableCell>
<TableCell class="text-xs">{{ c.client?.name || "—" }}</TableCell>
<TableCell class="text-xs">{{
c.start_date
? new Date(c.start_date).toLocaleDateString("sl-SI")
: "—"
}}</TableCell>
<TableCell class="text-xs">{{
c.promise_date
? new Date(c.promise_date).toLocaleDateString("sl-SI")
: "—"
}}</TableCell>
<TableCell>
<div v-if="c.selected_phone" class="text-xs">
{{ c.selected_phone.number }}
<Badge
v-if="c.selected_phone.is_mobile"
variant="secondary"
class="ml-1"
>mobitel</Badge
>
<Badge
v-if="c.selected_phone.is_validated"
variant="default"
class="ml-1"
>potrjen</Badge
>
</div>
<div v-else class="text-xs text-muted-foreground"></div>
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ c.no_phone_reason || "—" }}
</TableCell>
</TableRow>
<TableRow v-if="!contracts.data?.length">
<TableCell colspan="8" class="text-center text-muted-foreground h-24">
Ni rezultatov. Kliknite "Išči pogodbe" za prikaz.
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow
><TableCell colspan="8" class="text-center text-muted-foreground h-24"
>Nalaganje...</TableCell
></TableRow
>
</TableBody>
</Table>
</Card>
<div class="mt-3 flex items-center justify-between">
<div class="text-sm text-muted-foreground flex items-center gap-4">
<span v-if="contracts.data.length">
Prikazano stran {{ contracts.meta.current_page }} od
{{ contracts.meta.last_page }} (skupaj {{ contracts.meta.total }})
</span>
<div class="flex items-center gap-2">
<Label class="text-xs">Na stran:</Label>
<Select v-model="perPage" @update:model-value="loadContracts()">
<SelectTrigger class="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="10">10</SelectItem>
<SelectItem :value="25">25</SelectItem>
<SelectItem :value="50">50</SelectItem>
<SelectItem :value="100">100</SelectItem>
<SelectItem :value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex gap-2">
<Button
@click="goContractsPage(-1)"
:disabled="contracts.meta.current_page <= 1"
variant="outline"
size="sm"
>Nazaj</Button
>
<Button
@click="goContractsPage(1)"
:disabled="contracts.meta.current_page >= contracts.meta.last_page"
variant="outline"
size="sm"
>Naprej</Button
>
</div>
</div>
</div>
<Separator class="sm:col-span-3" />
<div class="sm:col-span-3 flex items-center justify-between gap-2">
<div class="text-sm">
<span class="font-medium">Izbrano: {{ selectedContractIds.size }}</span>
<span v-if="selectedContractIds.size > 0" class="ml-2 text-muted-foreground"
>({{
selectedContractIds.size === 1
? "1 pogodba"
: `${selectedContractIds.size} pogodb`
}})</span
>
</div>
<Button
@click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
>Ustvari paket</Button
>
</div>
</template>
</CardContent>
</Card>
<AppCard
title=""
padding="none"
@@ -771,7 +102,7 @@ function submitCreateFromContracts() {
<template #header>
<div class="flex items-center gap-2">
<PackageIcon size="18" />
<CardTitle class="uppercase">Uvozi</CardTitle>
<CardTitle class="uppercase">Paketi</CardTitle>
</div>
</template>
<DataTableNew2
@@ -780,10 +111,6 @@ function submitCreateFromContracts() {
:meta="packages"
route-name="admin.packages.index"
>
<template #cell-uuid="{ row }">
<span class="font-mono text-xs text-muted-foreground">{{ row.uuid }}</span>
</template>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
</template>
@@ -797,7 +124,9 @@ function submitCreateFromContracts() {
</template>
<template #cell-finished_at="{ row }">
<span class="text-xs text-muted-foreground">{{ row.finished_at ?? "—" }}</span>
<span class="text-xs text-muted-foreground">{{
fmtDateTime(row.finished_at) ?? "—"
}}</span>
</template>
<template #cell-actions="{ row }">
@@ -807,7 +136,7 @@ function submitCreateFromContracts() {
</Button>
<Button
v-if="row.status === 'draft'"
@click="deletePackage(row)"
@click="openDeleteDialog(row)"
:disabled="deletingId === row.id"
variant="ghost"
size="sm"
@@ -818,5 +147,30 @@ function submitCreateFromContracts() {
</template>
</DataTableNew2>
</AppCard>
<!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete"
>#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Prekliči</AlertDialogCancel>
<AlertDialogAction
@click="confirmDelete"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Izbriši
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AdminLayout>
</template>
@@ -16,6 +16,7 @@ import {
SelectValue,
} from "@/Components/ui/select";
import { Switch } from "@/Components/ui/switch";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { ref, watch, computed } from "vue";
const props = defineProps({
@@ -53,7 +54,7 @@ const form = useInertiaForm({
props.actions[0].decisions.length > 0
? props.actions[0].decisions[0].id
: null,
contract_uuid: props.contractUuid,
contract_uuids: props.contractUuid ? [props.contractUuid] : [],
send_auto_mail: true,
attach_documents: false,
attachment_document_ids: [],
@@ -95,7 +96,7 @@ watch(
watch(
() => props.contractUuid,
(cu) => {
form.contract_uuid = cu || null;
form.contract_uuids = cu ? [cu] : [];
}
);
@@ -103,7 +104,7 @@ watch(
() => props.show,
(visible) => {
if (visible) {
form.contract_uuid = props.contractUuid || null;
form.contract_uuids = props.contractUuid ? [props.contractUuid] : [];
}
}
);
@@ -119,20 +120,29 @@ const store = async () => {
return `${y}-${m}-${day}`;
};
const contractUuids =
Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
? form.contract_uuids
: null;
const isMultipleContracts = contractUuids && contractUuids.length > 1;
form
.transform((data) => ({
...data,
phone_view: props.phoneMode,
due_date: formatDateForSubmit(data.due_date),
contract_uuids: contractUuids,
create_for_all_contracts: isMultipleContracts,
attachment_document_ids:
templateAllowsAttachments.value && data.attach_documents
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
? data.attachment_document_ids
: [],
}))
.post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => {
close();
form.reset("due_date", "amount", "note");
form.reset("due_date", "amount", "note", "contract_uuids");
emit("saved");
},
});
@@ -165,13 +175,45 @@ const autoMailRequiresContract = computed(() => {
return types.includes("contract");
});
const autoMailDisabled = computed(() => {
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
const contractItems = computed(() => {
return pageContracts.value.map((c) => ({
value: c.uuid,
label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`,
}));
});
const autoMailDisabled = computed(() => {
if (!showSendAutoMail()) return false;
// Disable if multiple contracts selected
if (form.contract_uuids && form.contract_uuids.length > 1) return true;
// Disable if template requires contract but none selected
if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return true;
}
return false;
});
const autoMailDisabledHint = computed(() => {
return autoMailDisabled.value
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
: "";
if (!showSendAutoMail()) return "";
if (form.contract_uuids && form.contract_uuids.length > 1) {
return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
}
if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
}
return "";
});
watch(
() => autoMailDisabled.value,
@@ -231,9 +273,12 @@ const docsSource = computed(() => {
});
const availableContractDocs = computed(() => {
if (!form.contract_uuid) return [];
if (!form.contract_uuids || form.contract_uuids.length === 0) return [];
// Only show docs if exactly one contract is selected
if (form.contract_uuids.length > 1) return [];
const selectedUuid = form.contract_uuids[0];
const docs = docsSource.value;
const all = docs.filter((d) => d.contract_uuid === form.contract_uuid);
const all = docs.filter((d) => d.contract_uuid === selectedUuid);
if (!props.phoneMode) return all;
return all.filter((d) => {
const mime = (d.mime_type || "").toLowerCase();
@@ -264,14 +309,14 @@ watch(
[
() => props.phoneMode,
() => templateAllowsAttachments.value,
() => form.contract_uuid,
() => form.contract_uuids,
() => form.decision_id,
() => availableContractDocs.value.length,
],
() => {
if (!props.phoneMode) return;
if (!templateAllowsAttachments.value) return;
if (!form.contract_uuid) return;
if (!form.contract_uuids || form.contract_uuids.length !== 1) return;
const docs = availableContractDocs.value;
if (docs.length === 0) return;
form.attach_documents = true;
@@ -295,117 +340,148 @@ watch(
@confirm="store"
>
<form @submit.prevent="store">
<div class="space-y-4">
<div class="space-y-2">
<Label>Akcija</Label>
<Select v-model="form.action_id" :disabled="!actions || !actions.length">
<SelectTrigger>
<SelectValue placeholder="Izberi akcijo" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label>Akcija</Label>
<Select v-model="form.action_id" :disabled="!actions || !actions.length">
<SelectTrigger>
<SelectValue placeholder="Izberi akcijo" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!decisions || !decisions.length">
<SelectTrigger>
<SelectValue placeholder="Izberi odločitev" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="d in decisions" :key="d.id" :value="d.id">
{{ d.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!decisions || !decisions.length">
<SelectTrigger>
<SelectValue placeholder="Izberi odločitev" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="d in decisions" :key="d.id" :value="d.id">
{{ d.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="activityNote">Opomba</Label>
<Textarea
id="activityNote"
v-model="form.note"
class="block w-full"
placeholder="Opomba"
/>
</div>
<div class="space-y-2">
<Label>Pogodbe</Label>
<AppMultiSelect
v-model="form.contract_uuids"
:items="contractItems"
placeholder="Izberi pogodbe (neobvezno)"
search-placeholder="Išči pogodbo..."
empty-text="Ni pogodb."
:clearable="true"
:show-selected-chips="true"
/>
<p
v-if="form.contract_uuids && form.contract_uuids.length > 1"
class="text-xs text-muted-foreground"
>
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako
pogodbo).
</p>
</div>
<div class="space-y-2">
<Label for="activityDueDate">Datum zapadlosti</Label>
<DatePicker
id="activityDueDate"
v-model="form.due_date"
format="dd.MM.yyyy"
:error="form.errors.due_date"
/>
</div>
<div class="space-y-2">
<Label for="activityNote">Opomba</Label>
<Textarea
id="activityNote"
v-model="form.note"
class="block w-full max-h-72"
placeholder="Opomba"
/>
</div>
<div class="space-y-2">
<Label for="activityAmount">Znesek</Label>
<CurrencyInput
id="activityAmount"
v-model="form.amount"
:precision="{ min: 0, max: 4 }"
placeholder="0,00"
class="w-full"
/>
</div>
<div class="space-y-2">
<Label for="activityDueDate">Datum zapadlosti</Label>
<DatePicker
id="activityDueDate"
v-model="form.due_date"
format="dd.MM.yyyy"
:error="form.errors.due_date"
/>
</div>
<div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Switch
v-model="form.send_auto_mail"
:disabled="autoMailDisabled"
/>
<Label class="cursor-pointer">Send auto email</Label>
</div>
<div class="space-y-2">
<Label for="activityAmount">Znesek</Label>
<CurrencyInput
id="activityAmount"
v-model="form.amount"
:precision="{ min: 0, max: 4 }"
placeholder="0,00"
class="w-full"
/>
</div>
<div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
<Label class="cursor-pointer">Send auto email</Label>
</div>
<p v-if="autoMailDisabled" class="text-xs text-amber-600">
{{ autoMailDisabledHint }}
</p>
</div>
<p v-if="autoMailDisabled" class="text-xs text-amber-600">
{{ autoMailDisabledHint }}
</p>
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
<label class="inline-flex items-center gap-2">
<Switch v-model="form.attach_documents" />
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
</label>
<div
v-if="form.attach_documents"
class="mt-2 border rounded p-2 max-h-48 overflow-auto"
>
<div class="text-xs text-gray-600 mb-2">
Izberite dokumente, ki bodo poslani kot priponke:
</div>
<div class="space-y-1">
<template v-for="c in pageContracts" :key="c.uuid || c.id">
<div v-if="c.uuid === form.contract_uuid">
<div class="font-medium text-sm text-gray-700 mb-1">
Pogodba {{ c.reference }}
</div>
<div class="space-y-1">
<div
v-for="doc in availableContractDocs"
:key="doc.uuid || doc.id"
class="flex items-center gap-2 text-sm"
>
<Switch
:model-value="form.attachment_document_ids.includes(doc.id)"
@update:model-value="(checked) => {
<div
v-if="
templateAllowsAttachments &&
form.contract_uuids &&
form.contract_uuids.length === 1
"
class="mt-3"
>
<label class="inline-flex items-center gap-2">
<Switch v-model="form.attach_documents" />
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
</label>
<div
v-if="form.attach_documents"
class="mt-2 border rounded p-2 max-h-48 overflow-auto"
>
<div class="text-xs text-gray-600 mb-2">
Izberite dokumente, ki bodo poslani kot priponke:
</div>
<div class="space-y-1">
<template v-for="c in pageContracts" :key="c.uuid || c.id">
<div v-if="c.uuid === form.contract_uuids[0]">
<div class="font-medium text-sm text-gray-700 mb-1">
Pogodba {{ c.reference }}
</div>
<div class="space-y-1">
<div
v-for="doc in availableContractDocs"
:key="doc.uuid || doc.id"
class="flex items-center max-w-sm gap-2 text-sm"
>
<Switch
:model-value="form.attachment_document_ids.includes(doc.id)"
@update:model-value="
(checked) => {
if (checked) {
if (!form.attachment_document_ids.includes(doc.id)) {
form.attachment_document_ids.push(doc.id);
}
} else {
form.attachment_document_ids = form.attachment_document_ids.filter(id => id !== doc.id);
form.attachment_document_ids = form.attachment_document_ids.filter(
(id) => id !== doc.id
);
}
}"
/>
<span>{{ doc.original_name || doc.name }}</span>
}
"
/>
<div class="wrap-anywhere">
<p>
{{ doc.original_name || doc.name }}
</p>
<span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }},
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
@@ -413,22 +489,23 @@ watch(
</div>
</div>
</div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div>
</div>
</div>
</div>
<ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</ActionMessage>
</div>
</form>
</CreateDialog>
<ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</ActionMessage>
</div>
</form>
</CreateDialog>
</template>
@@ -0,0 +1,229 @@
<script setup>
import { ref, watch } from "vue";
import { router } from "@inertiajs/vue3";
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { ScrollArea } from "@/Components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Plus, Trash2 } from "lucide-vue-next";
const props = defineProps({
show: { type: Boolean, default: false },
client_case: { type: Object, required: true },
contract: { type: Object, default: null },
});
const emit = defineEmits(["close"]);
const processing = ref(false);
const metaEntries = ref([]);
// Extract meta entries from contract
function extractMetaEntries(contract) {
if (!contract?.meta) return [];
const results = [];
const visit = (node, keyName) => {
if (node === null || node === undefined) return;
if (Array.isArray(node)) {
node.forEach((el) => visit(el));
return;
}
if (typeof node === "object") {
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
if (hasValue || hasTitle) {
const title = (node.title || keyName || "").toString().trim() || keyName || "";
results.push({
title,
value: node.value ?? "",
type: node.type || "string",
});
return;
}
for (const [k, v] of Object.entries(node)) {
visit(v, k);
}
return;
}
if (keyName) {
results.push({ title: keyName, value: node ?? "", type: "string" });
}
};
visit(contract.meta, undefined);
return results;
}
// Initialize meta entries when dialog opens
watch(
() => props.show,
(newVal) => {
if (newVal && props.contract) {
const entries = extractMetaEntries(props.contract);
metaEntries.value =
entries.length > 0 ? entries : [{ title: "", value: "", type: "string" }];
}
}
);
function addEntry() {
metaEntries.value.push({ title: "", value: "", type: "string" });
}
function removeEntry(index) {
metaEntries.value.splice(index, 1);
if (metaEntries.value.length === 0) {
metaEntries.value.push({ title: "", value: "", type: "string" });
}
}
function close() {
emit("close");
}
function submit() {
if (!props.contract?.uuid || processing.value) return;
// Filter out empty entries and build meta object
const validEntries = metaEntries.value.filter((e) => e.title && e.title.trim() !== "");
const meta = {};
validEntries.forEach((entry) => {
meta[entry.title] = {
title: entry.title,
value: entry.value,
type: entry.type,
};
});
processing.value = true;
router.patch(
route("clientCase.contract.patchMeta", {
client_case: props.client_case.uuid,
uuid: props.contract.uuid,
}),
{ meta },
{
preserveScroll: true,
only: ["contracts"],
onSuccess: () => {
close();
processing.value = false;
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
}
</script>
<template>
<DialogModal :show="show" max-width="3xl" @close="close">
<template #title>
<h3 class="text-lg font-semibold leading-6 text-foreground">Uredi Meta podatke</h3>
</template>
<template #description>
Posodobi meta podatke za pogodbo {{ contract?.reference }}
</template>
<template #content>
<form id="meta-edit-form" @submit.prevent="submit" class="space-y-4">
<ScrollArea class="h-[60vh]">
<div class="space-y-3 pr-4">
<div
v-for="(entry, index) in metaEntries"
:key="index"
class="flex items-start gap-2 p-3 border rounded-lg bg-muted/20"
>
<div class="flex-1 space-y-3">
<div>
<Label :for="`meta-title-${index}`">Naziv</Label>
<Input
:id="`meta-title-${index}`"
v-model="entry.title"
placeholder="Vnesi naziv..."
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<Label :for="`meta-type-${index}`">Tip</Label>
<Select v-model="entry.type">
<SelectTrigger :id="`meta-type-${index}`" class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">Tekst</SelectItem>
<SelectItem value="number">Številka</SelectItem>
<SelectItem value="date">Datum</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label :for="`meta-value-${index}`">Vrednost</Label>
<Input
:id="`meta-value-${index}`"
v-model="entry.value"
:type="
entry.type === 'date'
? 'date'
: entry.type === 'number'
? 'number'
: 'text'
"
:step="entry.type === 'number' ? '0.01' : undefined"
placeholder="Vnesi vrednost..."
class="mt-1"
/>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
@click="removeEntry(index)"
:disabled="metaEntries.length === 1"
class="mt-6"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</ScrollArea>
<Button type="button" variant="outline" @click="addEntry" class="w-full">
<Plus class="h-4 w-4 mr-2" />
Dodaj vnos
</Button>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button type="button" variant="ghost" @click="close" :disabled="processing">
Prekliči
</Button>
<Button type="submit" form="meta-edit-form" :disabled="processing">
{{ processing ? "Shranjujem..." : "Shrani" }}
</Button>
</div>
</template>
</DialogModal>
</template>
@@ -15,6 +15,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -33,6 +34,16 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
client: { type: Object, default: null },
@@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
selectedContract.value = null;
};
// Meta edit dialog
const showMetaEditDialog = ref(false);
const openMetaEditDialog = (c) => {
selectedContract.value = c;
showMetaEditDialog.value = true;
};
const closeMetaEditDialog = () => {
showMetaEditDialog.value = false;
selectedContract.value = null;
};
// Columns configuration
const columns = computed(() => [
{ key: "reference", label: "Ref.", sortable: false, align: "center" },
@@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
<div class="text-gray-500">Ni meta podatkov.</div>
</template>
</div>
<div v-if="edit && row.active" class="border-t border-gray-200 mt-2 pt-2">
<button
type="button"
@click="openMetaEditDialog(row)"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded transition-colors"
>
<FontAwesomeIcon
:icon="faPenToSquare"
class="h-3.5 w-3.5 text-gray-600"
/>
<span>Uredi meta podatke</span>
</button>
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
:edit="edit"
/>
<ContractMetaEditDialog
:show="showMetaEditDialog"
:client_case="client_case"
:contract="selectedContract"
@close="closeMetaEditDialog"
/>
<!-- Generate Document Dialog -->
<CreateDialog
:show="showGenerateDialog"
@@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
@confirm="submitGenerate"
>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select
v-model="selectedTemplateSlug"
@change="onTemplateChange"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option :value="null">Izberi predlogo...</option>
<option v-for="t in templates" :key="t.slug" :value="t.slug">
{{ t.name }} (v{{ t.version }})
</option>
</select>
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
<SelectTrigger>
<SelectValue placeholder="Izberi predlogo..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
{{ t.name }} (v{{ t.version }})
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Custom inputs -->
@@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
<div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
<div class="space-y-3">
<div v-for="token in customTokenList" :key="token">
<label class="block text-sm font-medium text-gray-700">
<div v-for="token in customTokenList" :key="token" class="space-y-2">
<Label>
{{ token.replace(/^custom\./, "") }}
</label>
<input
</Label>
<Textarea
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
v-model="customInputs[token.replace(/^custom\./, '')]"
type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
rows="3"
/>
<Input
v-else
v-model="customInputs[token.replace(/^custom\./, '')]"
:type="
templateCustomTypes[token.replace(/^custom\./, '')] === 'date'
? 'date'
: templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
? 'number'
: 'text'
"
:step="
templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
? '0.01'
: undefined
"
/>
</div>
</div>
@@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
<!-- Address overrides -->
<div class="border-t border-gray-200 pt-4 space-y-3">
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3>
<div>
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label>
<select
v-model="clientAddressSource"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="client">Stranka</option>
<option value="case_person">Oseba primera</option>
</select>
<h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
<div class="space-y-2">
<Label>Naslov stranke</Label>
<Select v-model="clientAddressSource">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client">Stranka</SelectItem>
<SelectItem value="case_person">Oseba primera</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label>
<select
v-model="personAddressSource"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="case_person">Oseba primera</option>
<option value="client">Stranka</option>
</select>
<div class="space-y-2">
<Label>Naslov osebe</Label>
<Select v-model="personAddressSource">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="case_person">Oseba primera</SelectItem>
<SelectItem value="client">Stranka</SelectItem>
</SelectContent>
</Select>
</div>
</div>
+12 -10
View File
@@ -107,7 +107,7 @@ const onDocSaved = () => {
router.reload({ only: ["documents"] });
};
const viewer = ref({ open: false, src: "", title: "" });
const viewer = ref({ open: false, src: "", title: "", mimeType: "", filename: "" });
const openViewer = (doc) => {
const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -122,7 +122,13 @@ const openViewer = (doc) => {
client_case: props.client_case.uuid,
document: doc.uuid,
});
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
viewer.value = {
open: true,
src: url,
title: doc.name || doc.original_name,
mimeType: doc.mime_type || "",
filename: doc.original_name || doc.name || "",
};
} else {
const url =
isContractDoc && doc.contract_uuid
@@ -140,6 +146,8 @@ const openViewer = (doc) => {
const closeViewer = () => {
viewer.value.open = false;
viewer.value.src = "";
viewer.value.mimeType = "";
viewer.value.filename = "";
};
const clientDetails = ref(false);
@@ -210,14 +218,6 @@ const closeDrawer = () => {
drawerAddActivity.value = false;
};
const showClientDetails = () => {
clientDetails.value = false;
};
const hideClietnDetails = () => {
clientDetails.value = true;
};
// Attach segment to case
const showAttachSegment = ref(false);
const openAttachSegment = () => {
@@ -490,6 +490,8 @@ const submitAttachSegment = () => {
:show="viewer.open"
:src="viewer.src"
:title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer"
/>
</AppLayout>
+457 -82
View File
@@ -2,9 +2,14 @@
import AppLayout from "@/Layouts/AppLayout.vue";
import { computed, ref } from "vue";
import { Link, router, usePage } from "@inertiajs/vue3";
import axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Checkbox } from "@/Components/ui/checkbox";
import { Label } from "@/Components/ui/label";
import { Switch } from "@/Components/ui/switch";
import {
Select,
SelectContent,
@@ -19,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ButtonGroup } from "@/Components/ui/button-group";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { Filter, LinkIcon } from "lucide-vue-next";
import { Filter, LinkIcon, FileDown, LayoutIcon } from "lucide-vue-next";
import { Card } from "@/Components/ui/card";
import { Badge } from "@/Components/ui/badge";
import { hasPermission } from "@/Services/permissions";
import InputLabel from "@/Components/InputLabel.vue";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { toNumber } from "lodash";
import { FormControl, FormField, FormFieldArray, FormLabel } from "@/Components/ui/form";
import { Field, FieldLabel } from "@/Components/ui/field";
import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod";
import FormChangeSegment from "./Partials/FormChangeSegment.vue";
const props = defineProps({
client: Object,
contracts: Object,
filters: Object,
segments: Object,
segments: Array,
types: Object,
});
@@ -54,6 +72,48 @@ const selectedSegments = ref(
: []
);
const filterPopoverOpen = ref(false);
const selectedContracts = ref([]);
const changeSegmentDialogOpen = ref(false);
const contractTable = ref(null);
const exportDialogOpen = ref(false);
const exportScope = ref("current");
const exportColumns = ref([
"reference",
"customer",
"address",
"start",
"segment",
"balance",
]);
const exportError = ref("");
const isExporting = ref(false);
const exportableColumns = [
{ key: "reference", label: "Referenca" },
{ key: "customer", label: "Stranka" },
{ key: "address", label: "Naslov" },
{ key: "start", label: "Začetek" },
{ key: "segment", label: "Segment" },
{ key: "balance", label: "Stanje" },
];
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed(() => props.contracts?.total ?? 0);
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
const allColumnsSelected = computed(
() => exportColumns.value.length === exportableColumns.length
);
const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value
);
const segmentSelectItems = computed(() =>
props.segments.map((val, i) => ({
label: val.name,
value: val.id,
}))
);
function applyDateFilter() {
filterPopoverOpen.value = false;
@@ -124,6 +184,157 @@ function formatDate(value) {
return value;
}
}
function toggleAllColumns(checked) {
exportColumns.value = checked ? exportableColumns.map((col) => col.key) : [];
}
function handleColumnToggle(key, checked) {
if (checked) {
if (!exportColumns.value.includes(key)) {
exportColumns.value = [...exportColumns.value, key];
}
} else {
exportColumns.value = exportColumns.value.filter((col) => col !== key);
}
}
function setExportScopeFromSwitch(checked) {
exportScope.value = checked ? "all" : "current";
}
function openExportDialog() {
exportDialogOpen.value = true;
exportError.value = "";
}
function closeExportDialog() {
exportDialogOpen.value = false;
}
async function submitExport() {
if (exportColumns.value.length === 0) {
exportError.value = "Izberi vsaj en stolpec.";
return;
}
try {
exportError.value = "";
isExporting.value = true;
const payload = {
scope: exportScope.value,
columns: [...exportColumns.value],
from: dateRange.value?.start || "",
to: dateRange.value?.end || "",
search: search.value || "",
segments: selectedSegments.value.length > 0 ? selectedSegments.value.join(",") : "",
page: contractsCurrentPage.value,
per_page: contractsPerPage.value,
};
const response = await axios.post(
route("client.contracts.export", { uuid: props.client.uuid }),
payload,
{ responseType: "blob" }
);
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename =
extractFilenameFromHeaders(response.headers) || buildDefaultFilename();
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
exportDialogOpen.value = false;
} catch (error) {
console.error("Export error:", error);
console.error("Error response:", error.response);
let errorMessage = "Izvoz je spodletel. Poskusi znova.";
if (error.response?.status === 404) {
errorMessage = "Pot za izvoz ne obstaja. Prosim kontaktiraj administratorja.";
} else if (error.response?.status === 500) {
errorMessage = "Napaka na strežniku. Poskusi znova.";
} else if (error.response?.data) {
try {
const text = await error.response.data.text();
const json = JSON.parse(text);
errorMessage = json.message || errorMessage;
} catch (e) {
console.error("Could not parse error response:", e);
}
}
exportError.value = errorMessage;
} finally {
isExporting.value = false;
}
}
function slugify(value) {
if (!value) {
return "data";
}
const slug = value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
return slug || "data";
}
function buildDefaultFilename() {
const now = new Date();
const dd = String(now.getDate()).padStart(2, "0");
const mm = String(now.getMonth() + 1).padStart(2, "0");
const yy = String(now.getFullYear()).slice(-2);
const clientName = props.client?.person?.full_name || "stranka";
return `${dd}${mm}${yy}_${slugify(clientName)}-Pogodbe.xlsx`;
}
function extractFilenameFromHeaders(headers) {
if (!headers) {
return null;
}
const disposition =
headers["content-disposition"] || headers["Content-Disposition"] || "";
if (!disposition) {
return null;
}
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch (error) {
return utf8Match[1];
}
}
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null;
}
function handleSelectionChange(selectedKeys) {
selectedContracts.value = selectedKeys.map((val, i) => {
const num = toNumber(val);
return props.contracts.data[num].uuid;
});
}
function openDialogChangeSegment() {
changeSegmentDialogOpen.value = true;
}
function clearContractTableSelected() {
if (contractTable.value) {
contractTable.value.clearSelection();
}
}
</script>
<template>
@@ -193,9 +404,11 @@ function formatDate(value) {
</Link>
</div>
<DataTable
ref="contractTable"
:columns="[
{ key: 'reference', label: 'Referenca', sortable: false },
{ key: 'customer', label: 'Stranka', sortable: false },
{ key: 'address', label: 'Naslov', sortable: false },
{ key: 'start', label: 'Začetek', sortable: false },
{ key: 'segment', label: 'Segment', sortable: false },
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
@@ -215,94 +428,136 @@ function formatDate(value) {
row-key="uuid"
:only-props="['contracts']"
:page-size-options="[10, 15, 25, 50, 100]"
:enable-row-selection="true"
@selection:change="handleSelectionChange"
page-param-name="contracts_page"
per-page-param-name="contracts_per_page"
:show-toolbar="true"
>
<template #toolbar-filters>
<AppPopover
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="
dateRange?.start || dateRange?.end || selectedSegments?.length
"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{
[
dateRange?.start || dateRange?.end ? 1 : 0,
selectedSegments?.length ? 1 : 0,
].reduce((a, b) => a + b, 0)
}}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri pogodb</h4>
<p class="text-sm text-muted-foreground">
Izberite filtre za prikaz pogodb
</p>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Išči po referenci, stranki..."
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datumska območja</InputLabel>
<DateRangePicker
v-model="dateRange"
format="dd.MM.yyyy"
placeholder="Izberi datumska območja"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Segmenti</InputLabel>
<AppMultiSelect
v-model="selectedSegments"
:items="
segments.map((s) => ({ value: String(s.id), label: s.name }))
<template #toolbar-filters="{ table }">
<div class="flex flex-wrap items-center gap-2">
<AppPopover
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="
dateRange?.start || dateRange?.end || selectedSegments?.length
"
placeholder="Vsi segmenti"
search-placeholder="Išči segment..."
empty-text="Ni segmentov"
chip-variant="secondary"
/>
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="
!dateRange?.start &&
!dateRange?.end &&
selectedSegments.length === 0 &&
search === ''
"
@click="clearDateFilter"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyDateFilter">
Uporabi
</Button>
{{
[
dateRange?.start || dateRange?.end ? 1 : 0,
selectedSegments?.length ? 1 : 0,
].reduce((a, b) => a + b, 0)
}}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri pogodb</h4>
<p class="text-sm text-muted-foreground">
Izberite filtre za prikaz pogodb
</p>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Išči po referenci, stranki..."
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datumska območja</InputLabel>
<DateRangePicker
v-model="dateRange"
format="dd.MM.yyyy"
placeholder="Izberi datumska območja"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Segmenti</InputLabel>
<AppMultiSelect
v-model="selectedSegments"
:items="
segments.map((s) => ({
value: String(s.id),
label: s.name,
}))
"
placeholder="Vsi segmenti"
search-placeholder="Išči segment..."
empty-text="Ni segmentov"
chip-variant="secondary"
/>
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="
!dateRange?.start &&
!dateRange?.end &&
selectedSegments.length === 0 &&
search === ''
"
@click="clearDateFilter"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyDateFilter">
Uporabi
</Button>
</div>
</div>
</div>
</div>
</AppPopover>
</AppPopover>
<Button
variant="outline"
size="sm"
class="gap-2"
@click="openExportDialog"
>
<FileDown class="h-4 w-4" />
Izvozi v Excel
</Button>
<DropdownMenu v-if="table.getSelectedRowModel().rows.length > 0">
<DropdownMenuTrigger as-child>
<Button class="gap-2 px-3" variant="outline">
<Badge
class="h-5 min-w-5 rounded-full font-mono tabular-nums text-accent"
variant="destructive"
>
{{ table.getSelectedRowModel().rows.length }}
</Badge>
Akcija
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem @click="openDialogChangeSegment">
<LayoutIcon />
Spremeni segment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
@click="clearContractTableSelected"
v-if="table.getSelectedRowModel().rows.length > 0"
>
Odznači izbrane
</Button>
</div>
</template>
<template #cell-reference="{ row }">
<Link
@@ -315,6 +570,9 @@ function formatDate(value) {
<template #cell-customer="{ row }">
{{ row.client_case?.person?.full_name || "-" }}
</template>
<template #cell-address="{ row }">
{{ row.client_case?.person?.address?.address || "-" }}
</template>
<template #cell-start="{ row }">
{{ formatDate(row.start_date) }}
</template>
@@ -337,5 +595,122 @@ function formatDate(value) {
</div>
</div>
</div>
<!-- Excel export dialog -->
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title>
<div class="space-y-1">
<h3 class="text-lg font-semibold leading-6 text-foreground">Izvoz v Excel</h3>
<p class="text-sm text-muted-foreground">
Izberi stolpce in obseg podatkov za izvoz.
</p>
</div>
</template>
<template #content>
<form id="contract-export-form" class="space-y-5" @submit.prevent="submitExport">
<div class="space-y-3 rounded-lg border bg-muted/40 p-4">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">Obseg podatkov</p>
<p class="text-sm text-muted-foreground">
Preklopi, ali izvoziš samo trenutni pogled ali vse pogodbe.
</p>
</div>
<div
class="flex items-center gap-3 rounded-md bg-background px-3 py-2 shadow-sm"
>
<span class="text-xs font-medium text-muted-foreground">Stran</span>
<Switch
:model-value="exportScope === 'all'"
@update:modelValue="setExportScopeFromSwitch"
aria-label="Preklopi obseg izvoza"
/>
<span class="text-xs font-medium text-muted-foreground">Vse</span>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-lg border bg-background p-3 shadow-sm">
<p class="text-sm font-semibold text-foreground">Trenutna stran</p>
<p class="text-xs text-muted-foreground">
{{ currentPageCount }} zapisov
</p>
</div>
<div class="rounded-lg border bg-background p-3 shadow-sm">
<p class="text-sm font-semibold text-foreground">Vse pogodbe</p>
<p class="text-xs text-muted-foreground">{{ totalContracts }} zapisov</p>
</div>
</div>
</div>
<div class="space-y-4 rounded-lg border bg-muted/40 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">Stolpci</p>
<p class="text-sm text-muted-foreground">
Izberi, katere stolpce želiš vključiti v izvoz.
</p>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="export-columns-all"
:model-value="allColumnsSelected"
@update:modelValue="toggleAllColumns"
aria-label="Označi vse stolpce"
/>
<Label for="export-columns-all" class="text-sm text-muted-foreground">
Označi vse
</Label>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="col in exportableColumns"
:key="col.key"
class="flex items-start gap-3 rounded-lg border bg-background px-3 py-3 text-sm shadow-sm transition hover:border-primary/40"
:for="`export-col-${col.key}`"
>
<Checkbox
:id="`export-col-${col.key}`"
:model-value="exportColumns.includes(col.key)"
:value="col.key"
@update:modelValue="(checked) => handleColumnToggle(col.key, checked)"
class="mt-0.5"
/>
<div class="space-y-0.5">
<p class="font-medium text-foreground">{{ col.label }}</p>
<p class="text-xs text-muted-foreground">Vključi stolpec v datoteko.</p>
</div>
</label>
</div>
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
</div>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button type="button" variant="ghost" @click="closeExportDialog">
Prekliči
</Button>
<Button
type="submit"
form="contract-export-form"
:disabled="exportDisabled"
class="gap-2"
>
<span v-if="!isExporting">Prenesi Excel</span>
<span v-else>Pripravljam ...</span>
</Button>
</div>
</template>
</DialogModal>
<!-- Change segment selected contracts dialog -->
<FormChangeSegment
:show="changeSegmentDialogOpen"
@close="changeSegmentDialogOpen = false"
:segments="segmentSelectItems"
:contracts="selectedContracts"
:clear-selected-rows="clearContractTableSelected"
/>
</AppLayout>
</template>
+4 -6
View File
@@ -6,10 +6,8 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { hasPermission } from "@/Services/permissions";
import { Button } from "@/Components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card";
import { CardTitle } from "@/Components/ui/card";
import { Input } from "@/Components/ui/input";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import {
Select,
SelectContent,
@@ -27,8 +25,7 @@ import {
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import ActionMessage from "@/Components/ActionMessage.vue";
import { Mail, Plug2Icon, Plus, UsersRoundIcon } from "lucide-vue-next";
import { Plus, UsersRoundIcon } from "lucide-vue-next";
import { Separator } from "@/Components/ui/separator";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
@@ -162,7 +159,7 @@ const fmtCurrency = (v) => {
</script>
<template>
<AppLayout>
<AppLayout title="Clients">
<template #header> </template>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@@ -201,6 +198,7 @@ const fmtCurrency = (v) => {
:show-pagination="false"
:show-toolbar="true"
:hoverable="true"
:page-size="100"
row-key="uuid"
:striped="true"
empty-text="Ni najdenih naročnikov."
@@ -0,0 +1,155 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
} from "@/Components/ui/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { toTypedSchema } from "@vee-validate/zod";
import { useForm, Field as VeeField } from "vee-validate";
import { router } from "@inertiajs/vue3";
import { onMounted, ref } from "vue";
import z from "zod";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
segments: { type: Array, default: [] },
contracts: { type: Array, default: [] },
clearSelectedRows: { type: Function, default: () => console.log("test") },
});
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
};
const processing = ref(false);
// vee-validate Form setup
const formSchema = toTypedSchema(
z.object({
segment_id: z
.number()
.refine((val) => props.segments.find((item) => item.value == val) !== undefined, {
message: "Izbran segment ne obstaja v zbirki segmentov",
}),
})
);
const { handleSubmit, resetForm, errors } = useForm({
validationSchema: formSchema,
});
const onSubmit = handleSubmit((data) => {
processing.value = true;
router.patch(
route("contracts.segment"),
{
...data,
contracts: props.contracts,
},
{
onSuccess: () => {
router.reload({ only: ["contracts"] });
close();
resetForm();
props.clearSelectedRows();
processing.value = false;
},
onError: (e) => {
errors = e;
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
onMounted(() => {
console.log(props.segments);
});
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<h3 class="text-lg font-semibold leading-6 text-foreground">
Spremeni segment pogodbam
</h3>
</template>
<template #content>
<form id="segment-change-form" @submit.prevent="onSubmit">
<VeeField v-slot="{ field, errors }" name="segment_id">
<Field orientation="responsive" :data-invalid="!!errors.length">
<FieldContent>
<FieldLabel for="segment">Segment</FieldLabel>
<FieldDescription>Izberi segment za preusmeritev</FieldDescription>
<FieldError v-if="errors.length" :errors="errors" />
</FieldContent>
<Select
:model-value="field.value"
@update:model-value="field.onChange"
@blur="field.onBlur"
>
<SelectTrigger id="segment_id" :aria-invalid="!!errors.length">
<SelectValue placeholder="Izberi segment..."></SelectValue>
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto"> Auto </SelectItem>
<SelectItem
v-for="segment in segments"
:key="segment.label"
:value="segment.value"
>
{{ segment.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
</VeeField>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button
type="button"
:disabled="processing"
variant="ghost"
@click="
() => {
close();
resetForm();
}
"
>
Prekliči
</Button>
<Button type="submit" form="segment-change-form" :disabled="processing">
Potrdi
</Button>
</div>
</template>
</DialogModal>
</template>
<style></style>
+30 -13
View File
@@ -30,14 +30,15 @@ import AppPopover from "@/Components/app/ui/AppPopover.vue";
import InputLabel from "@/Components/InputLabel.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { toNumber } from "lodash";
const props = defineProps({
setting: Object,
unassignedContracts: Object,
assignedContracts: Object,
users: Array,
unassignedClients: Array,
assignedClients: Array,
unassignedClients: [Array, Object],
assignedClients: [Array, Object],
filters: Object,
});
@@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
: []
);
const unassignedContractTable = ref(null);
const form = useForm({
contract_uuid: null,
assigned_user_id: null,
@@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
console.log(selectedContractUuids.value);
}
function handleContractSelection(selected) {
selectedContractUuids.value = selected.map((val, i) => {
const num = toNumber(val);
return props.unassignedContracts.data[num].uuid;
});
}
// Format helpers (Slovenian formatting)
// Initialize search and filter from URL params
@@ -296,6 +307,7 @@ function assignSelected() {
bulkForm.contract_uuids = selectedContractUuids.value;
bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => {
unassignedContractTable.value.clearSelection();
selectedContractUuids.value = [];
bulkForm.contract_uuids = [];
},
@@ -304,7 +316,11 @@ function assignSelected() {
function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid };
form.transform(() => payload).post(route("fieldjobs.cancel"));
form
.transform(() => payload)
.post(route("fieldjobs.cancel"), {
preserveScroll: true,
});
}
// Column definitions for DataTableNew2
@@ -437,6 +453,7 @@ const assignedRows = computed(() =>
</div>
</div>
<DataTable
ref="unassignedContractTable"
:columns="unassignedColumns"
:data="unassignedRows"
:meta="{
@@ -449,6 +466,8 @@ const assignedRows = computed(() =>
links: unassignedContracts.links,
}"
row-key="uuid"
:enable-row-selection="true"
@selection:change="handleContractSelection"
:page-size="props.unassignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true"
@@ -482,7 +501,10 @@ const assignedRows = computed(() =>
<AppMultiSelect
v-model="filterUnassignedSelectedClient"
:items="
(props.unassignedClients || []).map((client) => ({
(Array.isArray(props.unassignedClients)
? props.unassignedClients
: props.unassignedClients?.data || []
).map((client) => ({
value: client.uuid,
label: client.person.full_name,
}))
@@ -497,14 +519,6 @@ const assignedRows = computed(() =>
</AppPopover>
</div>
</template>
<template #cell-_select="{ row }">
<Checkbox
@update:model-value="
(checked) => toggleContractSelection(row.uuid, checked)
"
/>
</template>
<template #cell-case_person="{ row }">
<Link
v-if="row.client_case?.uuid"
@@ -605,7 +619,10 @@ const assignedRows = computed(() =>
<AppMultiSelect
v-model="filterAssignedSelectedClient"
:items="
(props.assignedClients || []).map((client) => ({
(Array.isArray(props.assignedClients)
? props.assignedClients
: props.assignedClients?.data || []
).map((client) => ({
value: client.uuid,
label: client.person.full_name,
}))
+1 -1
View File
@@ -245,7 +245,7 @@ async function startImport() {
<!-- Has Header Checkbox -->
<div class="flex items-center space-x-2">
<Checkbox id="has-header" v-model:checked="form.has_header" />
<Checkbox id="has-header" :model-value="form.has_header" />
<Label
for="has-header"
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+13 -1
View File
@@ -1094,6 +1094,16 @@ async function fetchEvents() {
}
}
async function downloadImport() {
if (!importId.value) return;
try {
const url = route("imports.download", { import: importId.value });
window.location.href = url;
} catch (e) {
console.error("Download failed", e);
}
}
// Simulation (generic or payments) state
const showPaymentSim = ref(false);
const paymentSimLoading = ref(false);
@@ -1307,7 +1317,8 @@ async function fetchSimulation() {
<Checkbox
:id="'show-missing-checkbox'"
:checked="showMissingEnabled"
@update:checked="
:model-value="showMissingEnabled"
@update:model-value="
(val) => {
showMissingEnabled = val;
saveImportOptions();
@@ -1339,6 +1350,7 @@ async function fetchSimulation() {
:can-process="canProcess"
:selected-mappings-count="selectedMappingsCount"
@preview="openPreview"
@download="downloadImport"
@save-mappings="saveMappings"
@process-import="processImport"
@simulate="openSimulation"
@@ -4,9 +4,10 @@ import {
ArrowPathIcon,
BeakerIcon,
ArrowDownOnSquareIcon,
ArrowDownTrayIcon,
} from "@heroicons/vue/24/outline";
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
importId: [Number, String],
@@ -16,54 +17,68 @@ const props = defineProps({
canProcess: Boolean,
selectedMappingsCount: Number,
});
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]);
const emits = defineEmits([
"preview",
"save-mappings",
"process-import",
"simulate",
"download",
]);
</script>
<template>
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
<div class="flex flex-wrap gap-2 items-center">
<!-- Download button - always visible -->
<Button
variant="secondary"
@click.prevent="$emit('preview')"
@click.prevent="$emit('download')"
:disabled="!importId"
title="Preznesi originalno uvozno datoteko"
>
<EyeIcon class="h-4 w-4 mr-2" />
Predogled vrstic
</Button>
<Button
variant="default"
class="bg-orange-600 hover:bg-orange-700"
@click.prevent="$emit('save-mappings')"
:disabled="!importId || processing || savingMappings || isCompleted"
title="Shrani preslikave za ta uvoz"
>
<span
v-if="savingMappings"
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
></span>
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
<span>Shrani preslikave</span>
<Badge
v-if="selectedMappingsCount"
variant="secondary"
class="ml-2 text-xs"
>{{ selectedMappingsCount }}</Badge>
</Button>
<Button
variant="default"
class="bg-purple-600 hover:bg-purple-700"
@click.prevent="$emit('process-import')"
:disabled="!canProcess"
>
<BeakerIcon class="h-4 w-4 mr-2" />
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
</Button>
<Button
variant="default"
class="bg-blue-600 hover:bg-blue-700"
@click.prevent="$emit('simulate')"
:disabled="!importId || processing"
>
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
Simulacija vnosa
<ArrowDownTrayIcon class="h-4 w-4" />
Prenos datoteko
</Button>
<!-- Other action buttons - only when not completed -->
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
<Button variant="secondary" @click.prevent="$emit('preview')" :disabled="!importId">
<EyeIcon class="h-4 w-4 mr-2" />
Predogled vrstic
</Button>
<Button
variant="default"
class="bg-orange-600 hover:bg-orange-700"
@click.prevent="$emit('save-mappings')"
:disabled="!importId || processing || savingMappings || isCompleted"
title="Shrani preslikave za ta uvoz"
>
<span
v-if="savingMappings"
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
></span>
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
<span>Shrani preslikave</span>
<Badge v-if="selectedMappingsCount" variant="secondary" class="ml-2 text-xs">{{
selectedMappingsCount
}}</Badge>
</Button>
<Button
variant="default"
class="bg-purple-600 hover:bg-purple-700"
@click.prevent="$emit('process-import')"
:disabled="!canProcess"
>
<BeakerIcon class="h-4 w-4 mr-2" />
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
</Button>
<Button
variant="default"
class="bg-blue-600 hover:bg-blue-700"
@click.prevent="$emit('simulate')"
:disabled="!importId || processing"
>
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
Simulacija vnosa
</Button>
</div>
</div>
</template>
@@ -2,9 +2,12 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { ChevronRightIcon } from "@heroicons/vue/24/outline";
import { computed, ref } from "vue";
const props = defineProps({
show: Boolean,
limit: Number,
@@ -14,72 +17,187 @@ const props = defineProps({
truncated: Boolean,
hasHeader: Boolean,
})
const emits = defineEmits(['close','change-limit','refresh'])
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
// State
const selectedRow = ref(null);
const hideEmptyRows = ref(true);
// Filter out columns with empty headers
const visibleColumns = computed(() => {
if (!props.columns) return [];
return props.columns.filter(col => col && String(col).trim() !== '');
});
// Check if row is empty (first 2 columns are empty)
function isRowEmpty(row) {
if (!visibleColumns.value || visibleColumns.value.length === 0) return false;
const firstCols = visibleColumns.value.slice(0, 2);
return firstCols.every(col => !row[col] || String(row[col]).trim() === '');
}
// Filtered rows
const visibleRows = computed(() => {
if (!props.rows) return [];
let filtered = props.rows;
if (hideEmptyRows.value) {
filtered = filtered.filter(r => !isRowEmpty(r));
}
return filtered.map((r, idx) => ({ ...r, index: idx + 1 }));
});
// Select row
function selectRow(row) {
selectedRow.value = row;
}
function onLimit(val) {
emits('change-limit', Number(val));
emits('refresh');
}
</script>
<template>
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
</DialogHeader>
<div class="flex items-center gap-3 pb-3 border-b">
<div class="flex items-center gap-2">
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }">
<SelectTrigger id="limit-select" class="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="300">300</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
<DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
<!-- Header -->
<div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900">CSV Preview</h2>
<p class="text-sm text-gray-500 mt-1">
Showing {{ visibleRows.length }} of {{ rows.length }} rows
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
<Select :model-value="String(limit)" @update:model-value="onLimit">
<SelectTrigger id="limit-select" class="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="300">300</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
</div>
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</Button>
<div class="flex items-center gap-2">
<Checkbox
id="hide-empty-rows"
:checked="hideEmptyRows"
@update:checked="(val) => hideEmptyRows = val"
/>
<Label for="hide-empty-rows" class="text-xs cursor-pointer">
Hide empty rows
</Label>
</div>
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
Truncated at limit
</Badge>
</div>
</div>
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</Button>
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
Truncated at limit
</Badge>
</div>
<div class="flex-1 overflow-auto border rounded-lg">
<Table>
<TableHeader class="sticky top-0 bg-white z-10">
<TableRow>
<TableHead class="w-16">#</TableHead>
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
Loading
</TableCell>
</TableRow>
<TableRow v-for="(r, idx) in rows" :key="idx">
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
{{ r[col] }}
</TableCell>
</TableRow>
<TableRow v-if="!loading && !rows.length">
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
No rows
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- Split View -->
<div class="flex-1 flex overflow-hidden">
<!-- Left Panel - Row List -->
<div class="w-96 border-r bg-gray-50 overflow-y-auto">
<div v-if="loading" class="p-8 text-center text-gray-500">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
Loading...
</div>
<div v-else-if="!visibleRows.length" class="p-8 text-center text-gray-500">
No rows to display
</div>
<div v-else class="divide-y">
<button
v-for="row in visibleRows"
:key="row.index"
@click="selectRow(row)"
class="w-full px-4 py-3 text-left hover:bg-white transition-colors"
:class="{
'bg-white shadow-sm': selectedRow?.index === row.index,
}"
>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Row Number -->
<div class="flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-semibold">
{{ row.index }}
</div>
</div>
<!-- Row Preview -->
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-gray-900 mb-1">
Row #{{ row.index }}
</div>
<div class="text-xs text-gray-600 truncate">
{{
visibleColumns.slice(0, 2).map(col => row[col]).filter(Boolean).join(' • ') || 'Empty row'
}}
</div>
</div>
</div>
<!-- Arrow -->
<ChevronRightIcon class="h-4 w-4 text-gray-400 flex-shrink-0" />
</div>
</button>
</div>
</div>
<!-- Right Panel - Row Details -->
<div v-if="selectedRow" class="flex-1 overflow-y-auto p-6">
<!-- Row Header -->
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900">
Row #{{ selectedRow.index }}
</h3>
<p class="text-sm text-gray-500">Full row details</p>
</div>
<!-- Row Data -->
<div class="bg-gray-50 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-3">
<div
v-for="col in visibleColumns"
:key="col"
class="flex items-start gap-3 py-2 border-b border-gray-200 last:border-0"
>
<dt class="text-sm font-medium text-gray-600 w-48 flex-shrink-0">
{{ col }}
</dt>
<dd class="text-sm text-gray-900 flex-1 font-medium whitespace-pre-wrap break-words">
{{ selectedRow[col] || '—' }}
</dd>
</div>
</dl>
</div>
</div>
<!-- Empty State for Right Panel -->
<div v-else class="flex-1 flex items-center justify-center text-gray-400">
<div class="text-center">
<div class="text-5xl mb-3">📄</div>
<p class="text-sm">Select a row to view details</p>
</div>
</div>
</div>
<div class="text-xs text-gray-500 pt-3 border-t">
Showing up to {{ limit }} rows from source file.
<!-- Footer -->
<div class="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500">
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
Click a row to view full details
</div>
</DialogContent>
</Dialog>
@@ -1,10 +1,24 @@
<script setup>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
import { Checkbox } from '@/Components/ui/checkbox';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import { ScrollArea } from '@/Components/ui/scroll-area';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({
rows: Array,
@@ -19,12 +33,12 @@ const props = defineProps({
mappingError: String,
show: { type: Boolean, default: true },
fieldsForEntity: Function,
})
const emits = defineEmits(['update:rows','save'])
});
const emits = defineEmits(["update:rows", "save"]);
function duplicateTarget(row){
if(!row || !row.entity || !row.field) return false
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
function duplicateTarget(row) {
if (!row || !row.entity || !row.field) return false;
return props.duplicateTargets?.has?.(row.entity + "." + row.field) || false;
}
</script>
<template>
@@ -32,137 +46,192 @@ function duplicateTarget(row){
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">
Detected Columns
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge>
<Badge variant="outline" class="ml-2 text-[10px]">{{
detected?.has_header ? "header" : "positional"
}}</Badge>
</h3>
<div class="text-xs text-muted-foreground">
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }},
delimiter: {{ detected?.delimiter || "auto" }}
</div>
</div>
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p>
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">
{{ detectedNote }}
</p>
<div class="relative border rounded-lg">
<ScrollArea class="h-[420px]">
<Table>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow class="hover:bg-transparent">
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead>
<TableHead class="w-[180px] bg-muted/95 backdrop-blur"
>Source column</TableHead
>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
<TableHead class="w-[130px] bg-muted/95 backdrop-blur"
>Apply mode</TableHead
>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur"
>Skip</TableHead
>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''">
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
<TableCell>
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select entity..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
:model-value="row.field || ''"
@update:model-value="(val) => row.field = val || ''"
:disabled="isCompleted"
:class="duplicateTarget(row) ? 'border-destructive' : ''"
>
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).key"
type="text"
class="h-8 text-xs"
placeholder="e.g. monthly_rent"
:disabled="isCompleted"
/>
<span v-else class="text-muted-foreground text-xs"></span>
</TableCell>
<TableCell>
<Select
v-if="row.field === 'meta'"
:model-value="(row.options ||= {}).type || 'string'"
@update:model-value="(val) => (row.options ||= {}).type = val"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<span v-else class="text-muted-foreground text-xs"></span>
</TableCell>
<TableCell>
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">None</SelectItem>
<SelectItem value="trim">Trim</SelectItem>
<SelectItem value="upper">Uppercase</SelectItem>
<SelectItem value="lower">Lowercase</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="keyref">Keyref</SelectItem>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="insert">Insert only</SelectItem>
<SelectItem value="update">Update only</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell class="text-center">
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" />
</TableCell>
</TableRow>
</TableBody>
</Table>
<TableRow
v-for="(row, idx) in rows"
:key="idx"
:class="duplicateTarget(row) ? 'bg-destructive/10' : ''"
>
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
<TableCell>
<Select
:model-value="row.entity || ''"
@update:model-value="(val) => (row.entity = val || '')"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select entity..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="opt in entityOptions"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</SelectItem
>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
:model-value="row.field || ''"
@update:model-value="(val) => (row.field = val || '')"
:disabled="isCompleted"
:class="duplicateTarget(row) ? 'border-destructive' : ''"
>
<SelectTrigger
class="h-8 text-xs"
:class="
duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''
"
>
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="f in fieldsForEntity(row.entity)"
:key="f"
:value="f"
>{{ f }}</SelectItem
>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).key"
type="text"
class="h-8 text-xs"
placeholder="e.g. monthly_rent"
:disabled="isCompleted"
/>
<span v-else class="text-muted-foreground text-xs"></span>
</TableCell>
<TableCell>
<Select
v-if="row.field === 'meta'"
:model-value="(row.options ||= {}).type || 'string'"
@update:model-value="(val) => ((row.options ||= {}).type = val)"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<span v-else class="text-muted-foreground text-xs"></span>
</TableCell>
<TableCell>
<Select
:model-value="row.transform || 'none'"
@update:model-value="
(val) => (row.transform = val === 'none' ? '' : val)
"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">None</SelectItem>
<SelectItem value="trim">Trim</SelectItem>
<SelectItem value="upper">Uppercase</SelectItem>
<SelectItem value="lower">Lowercase</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
:model-value="row.apply_mode || 'both'"
@update:model-value="(val) => (row.apply_mode = val)"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="keyref">Keyref</SelectItem>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="insert">Insert only</SelectItem>
<SelectItem value="update">Update only</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell class="text-center">
<Checkbox
:model-value="row.skip"
@update:model-value="(val) => (row.skip = val)"
:disabled="isCompleted"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</ScrollArea>
</div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2">
<div
v-if="mappingSaved"
class="text-sm text-emerald-700 mt-2 flex items-center gap-2"
>
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
<span>{{ mappingSavedCount }} mappings saved</span>
</div>
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div>
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">
{{ mappingError }}
</div>
<div v-if="missingCritical?.length" class="mt-2">
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
<Badge variant="destructive" class="text-xs"
>Missing critical: {{ missingCritical.join(", ") }}</Badge
>
</div>
</div>
</template>
+181 -170
View File
@@ -1,15 +1,40 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref, computed, watch } from "vue";
import Dropdown from "@/Components/Dropdown.vue";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { Checkbox } from "@/Components/ui/checkbox";
import { Bell, BellOff, Check, ChevronDown, X, Inbox } from "lucide-vue-next";
import TableActions from "@/Components/DataTable/TableActions.vue";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { number } from "zod";
import { toNumber } from "lodash";
const props = defineProps({
activities: { type: Object, required: true },
today: { type: String, required: true },
// Optional: full list of clients with unread items to populate filter dropdown
clients: { type: Array, default: () => [] },
});
@@ -21,6 +46,7 @@ function fmtDate(d) {
return String(d);
}
}
function fmtEUR(value) {
if (value === null || value === undefined) return "—";
const num = typeof value === "string" ? Number(value) : value;
@@ -34,13 +60,12 @@ function fmtEUR(value) {
return formatted.replace("\u00A0", " ");
}
// --- Client filter (like Segments/Show.vue) ---
// Client filter
const urlParams = new URLSearchParams(window.location.search);
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
const selectedClient = ref(initialClient);
const clientOptions = computed(() => {
// Prefer server-provided clients list; fallback to deriving from rows
const list =
Array.isArray(props.clients) && props.clients.length
? props.clients
@@ -72,43 +97,20 @@ watch(selectedClient, (val) => {
});
});
// Row selection - connected to DataTableNew2's built-in selection
const selectedRows = ref([]);
const dataTableRef = ref(null);
function toggleSelectAll() {
if (selectedRows.value.length === (props.activities.data?.length || 0)) {
selectedRows.value = [];
} else {
selectedRows.value = (props.activities.data || []).map((row) => row.id);
}
}
function toggleRowSelection(id) {
const idx = selectedRows.value.indexOf(id);
if (idx > -1) {
selectedRows.value.splice(idx, 1);
} else {
selectedRows.value.push(id);
}
}
function isRowSelected(id) {
return selectedRows.value.includes(id);
}
function isAllSelected() {
return (
(props.activities.data?.length || 0) > 0 &&
selectedRows.value.length === (props.activities.data?.length || 0)
);
}
function isIndeterminate() {
return (
selectedRows.value.length > 0 &&
selectedRows.value.length < (props.activities.data?.length || 0)
);
function handleSelectionChange(selectedKeys) {
selectedRows.value = selectedKeys.map((val, i) => {
const nu = toNumber(val);
return props.activities.data[val].id;
});
console.log(selectedRows.value);
}
// Mark as read actions
function markRead(id) {
router.patch(
route("notifications.activity.read"),
@@ -130,143 +132,131 @@ function markReadBulk() {
preserveScroll: true,
onSuccess: () => {
selectedRows.value = [];
// Clear the selection state in DataTable
if (dataTableRef.value) {
dataTableRef.value.clearSelection();
}
},
}
);
}
// Table columns definition (select column is auto-generated by enableRowSelection)
const columns = [
{ key: "what", label: "Zadeva", sortable: false },
{ key: "partner", label: "Partner", sortable: false },
{ key: "balance", label: "Stanje", sortable: false, align: "right" },
{ key: "due", label: "Zapadlost", sortable: false },
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
];
const rows = computed(() => props.activities?.data || []);
</script>
<template>
<AppLayout title="Obvestila">
<template #header></template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1 py-3">
<div class="pb-3">
<SectionTitle>
<template #title>Neprikazana obvestila</template>
<template #description>Do danes: {{ fmtDate(today) }}</template>
</SectionTitle>
</div>
<!-- Filters -->
<div class="mb-4 flex items-center gap-3">
<div class="flex-1 max-w-sm">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Partner</label
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"
>
<Bell class="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle>Neprikazana obvestila</CardTitle>
<CardDescription>Do danes: {{ fmtDate(today) }}</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent class="p-0">
<!-- Client Filter -->
<div class="mb-6 px-6 flex items-end gap-3">
<div class="flex-1 max-w-sm space-y-2">
<Label for="client-filter">Partner</Label>
<div class="flex items-center gap-2">
<select
v-model="selectedClient"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
>
<option value="">Vsi partnerji</option>
<option
v-for="opt in clientOptions"
:key="opt.value || opt.label"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<button
<Select v-model="selectedClient">
<SelectTrigger id="client-filter">
<SelectValue placeholder="Vsi partnerji" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in clientOptions"
:key="opt.value || opt.label"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
v-if="selectedClient"
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
variant="ghost"
size="icon"
@click="selectedClient = ''"
title="Počisti filter"
>
Počisti
</button>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DataTableServer
:columns="[
{ key: 'select', label: '', sortable: false, width: '50px' },
{ key: 'what', label: 'Zadeva', sortable: false },
{ key: 'partner', label: 'Partner', sortable: false },
{
key: 'balance',
label: 'Stanje',
sortable: false,
align: 'right',
class: 'w-40',
},
{ key: 'due', label: 'Zapadlost', sortable: true, class: 'w-28' },
]"
:rows="activities.data || []"
:meta="{
current_page: activities.current_page,
per_page: activities.per_page,
total: activities.total,
last_page: activities.last_page,
}"
<!-- Data Table -->
<DataTable
ref="dataTableRef"
:columns="columns"
:data="rows"
:meta="activities"
route-name="notifications.unread"
page-param-name="unread-page"
:only-props="['activities']"
:query="{ client: selectedClient || undefined }"
:page-size="15"
:page-size-options="[10, 15, 25, 50]"
:show-pagination="true"
:show-toolbar="true"
:hoverable="true"
:enable-row-selection="true"
row-key="id"
empty-text="Trenutno ni neprikazanih obvestil."
@selection:change="handleSelectionChange"
>
<template #toolbar-extra>
<div v-if="selectedRows.length" class="flex items-center gap-2">
<div class="text-sm text-gray-700">
Izbrano: <span class="font-medium">{{ selectedRows.length }}</span>
<!-- Bulk Actions Toolbar -->
<template #toolbar-filters>
<div v-if="selectedRows.length" class="flex items-center gap-3">
<div class="text-sm text-muted-foreground">
Izbrano:
<span class="font-medium text-foreground">{{
selectedRows.length
}}</span>
</div>
<Dropdown width="48" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 text-gray-700 bg-white hover:bg-gray-50"
>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-2">
Akcije
<svg
class="ml-1 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z"
clip-rule="evenodd"
/>
</svg>
</button>
</template>
<template #content>
<button
type="button"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
@click="markReadBulk"
>
<ChevronDown class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem @click="markReadBulk">
<Check class="h-4 w-4" />
Označi kot prebrano
</button>
</template>
</Dropdown>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
<template #header-select>
<input
type="checkbox"
:checked="isAllSelected()"
:indeterminate="isIndeterminate()"
@change="toggleSelectAll"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</template>
<template #cell-select="{ row }">
<input
type="checkbox"
:checked="isRowSelected(row.id)"
@change="toggleRowSelection(row.id)"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</template>
<!-- What Column -->
<template #cell-what="{ row }">
<div class="font-medium text-gray-800 truncate">
<div class="font-medium truncate">
<template v-if="row.contract?.uuid">
Pogodba:
<span class="text-muted-foreground">Pogodba:</span>
<Link
v-if="row.contract?.client_case?.uuid"
:href="
@@ -274,27 +264,31 @@ function markReadBulk() {
client_case: row.contract.client_case.uuid,
})
"
class="text-indigo-600 hover:underline"
class="ml-1 text-primary hover:underline"
>
{{ row.contract?.reference || "—" }}
</Link>
<span v-else>{{ row.contract?.reference || "" }}</span>
<span v-else class="ml-1">{{ row.contract?.reference || "—" }}</span>
</template>
<template v-else>
Primer:
<span class="text-muted-foreground">Primer:</span>
<Link
v-if="row.client_case?.uuid"
:href="
route('clientCase.show', { client_case: row.client_case.uuid })
"
class="text-indigo-600 hover:underline"
class="ml-1 text-primary hover:underline"
>
{{ row.client_case?.person?.full_name || "—" }}
</Link>
<span v-else>{{ row.client_case?.person?.full_name || "" }}</span>
<span v-else class="ml-1">{{
row.client_case?.person?.full_name || "—"
}}</span>
</template>
</div>
</template>
<!-- Partner Column -->
<template #cell-partner="{ row }">
<div class="truncate">
{{
@@ -304,34 +298,51 @@ function markReadBulk() {
}}
</div>
</template>
<!-- Balance Column -->
<template #cell-balance="{ row }">
<div class="text-right">
<div class="text-right font-medium">
<span v-if="row.contract">{{
fmtEUR(row.contract?.account?.balance_amount)
}}</span>
<span v-else></span>
<span v-else class="text-muted-foreground"></span>
</div>
</template>
<!-- Due Date Column -->
<template #cell-due="{ row }">
{{ fmtDate(row.due_date) }}
</template>
<template #actions="{ row }">
<button
type="button"
class="text-[12px] text-gray-500 hover:text-gray-700"
@click="markRead(row.id)"
>
Označi kot prebrano
</button>
</template>
<template #empty>
<div class="p-6 text-center text-gray-500">
Trenutno ni neprikazanih obvestil.
<div class="text-sm">
{{ fmtDate(row.due_date) }}
</div>
</template>
</DataTableServer>
</div>
</div>
<!-- Actions Column -->
<template #cell-actions="{ row }">
<TableActions>
<ActionMenuItem @click="markRead(row.id)" label="Označi kot prebrano">
<BellOff class="mr-2 h-4 w-4" />
Označi kot prebrano
</ActionMenuItem>
</TableActions>
</template>
<!-- Empty State -->
<template #empty>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-muted"
>
<Inbox class="h-10 w-10 text-muted-foreground" />
</div>
<h3 class="mt-4 text-lg font-semibold">Ni neprikazanih obvestil</h3>
<p class="mt-2 text-sm text-muted-foreground">
Trenutno nimate nobenih neprikazanih obvestil.
</p>
</div>
</template>
</DataTable>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
+8 -2
View File
@@ -67,7 +67,7 @@ const props = defineProps({
completed_mode: { type: Boolean, default: false },
});
const viewer = reactive({ open: false, src: "", title: "" });
const viewer = reactive({ open: false, src: "", title: "", mimeType: "", filename: "" });
function openViewer(doc) {
const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -85,6 +85,8 @@ function openViewer(doc) {
viewer.open = true;
viewer.src = url;
viewer.title = doc.original_name || doc.name;
viewer.mimeType = doc.mime_type || "";
viewer.filename = doc.original_name || doc.name || "";
} else {
const url =
isContractDoc && doc.contract_uuid
@@ -102,6 +104,8 @@ function openViewer(doc) {
function closeViewer() {
viewer.open = false;
viewer.src = "";
viewer.mimeType = "";
viewer.filename = "";
}
function formatAmount(val) {
@@ -454,7 +458,7 @@ const clientSummary = computed(() => {
:key="a.id"
class="bg-gray-50/70 dark:bg-gray-800/50"
>
<CardHeader class="pb-3">
<CardHeader>
<div class="flex items-start justify-between gap-3">
<CardTitle class="text-sm font-medium truncate">
{{ activityActionLine(a) || "Aktivnost" }}
@@ -610,6 +614,8 @@ const clientSummary = computed(() => {
:show="viewer.open"
:src="viewer.src"
:title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer"
/>
<ActivityDrawer
@@ -1,12 +1,12 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import DangerButton from '@/Components/DangerButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/Components/ui/alert-dialog';
import InputError from '@/Components/InputError.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Trash2, AlertTriangle } from 'lucide-vue-next';
const confirmingUserDeletion = ref(false);
const passwordInput = ref(null);
@@ -38,65 +38,68 @@ const closeModal = () => {
</script>
<template>
<ActionSection>
<template #title>
Delete Account
</template>
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<Trash2 class="h-5 w-5 text-destructive" />
<CardTitle>Delete Account</CardTitle>
</div>
<CardDescription>
Permanently delete your account.
</CardDescription>
</CardHeader>
<template #description>
Permanently delete your account.
</template>
<template #content>
<div class="max-w-xl text-sm text-gray-600">
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
<CardContent class="space-y-4">
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div class="flex gap-3">
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<p class="text-sm text-foreground">
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
</p>
</div>
</div>
<div class="mt-5">
<DangerButton @click="confirmUserDeletion">
Delete Account
</DangerButton>
</div>
<Button variant="destructive" @click="confirmUserDeletion">
<Trash2 class="h-4 w-4 mr-2" />
Delete Account
</Button>
</CardContent>
<!-- Delete Account Confirmation Modal -->
<DialogModal :show="confirmingUserDeletion" @close="closeModal">
<template #title>
Delete Account
</template>
<!-- Delete Account Confirmation Dialog -->
<AlertDialog :open="confirmingUserDeletion" @update:open="closeModal">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
</AlertDialogDescription>
</AlertDialogHeader>
<template #content>
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
<div class="py-4">
<Input
ref="passwordInput"
v-model="form.password"
type="password"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="deleteUser"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="mt-4">
<TextInput
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="deleteUser"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal">
<AlertDialogFooter>
<Button variant="outline" @click="closeModal">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
</Button>
<Button
variant="destructive"
:disabled="form.processing"
@click="deleteUser"
>
Delete Account
</DangerButton>
</template>
</DialogModal>
</template>
</ActionSection>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
</template>
@@ -1,141 +1,164 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import ActionSection from '@/Components/ActionSection.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputError from '@/Components/InputError.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import InputError from "@/Components/InputError.vue";
import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
defineProps({
sessions: Array,
sessions: Array,
});
const confirmingLogout = ref(false);
const passwordInput = ref(null);
const form = useForm({
password: '',
password: "",
});
const confirmLogout = () => {
confirmingLogout.value = true;
confirmingLogout.value = true;
setTimeout(() => passwordInput.value.focus(), 250);
setTimeout(() => passwordInput.value.focus(), 250);
};
const logoutOtherBrowserSessions = () => {
form.delete(route('other-browser-sessions.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.value.focus(),
onFinish: () => form.reset(),
});
form.delete(route("other-browser-sessions.destroy"), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.value.focus(),
onFinish: () => form.reset(),
});
};
const closeModal = () => {
confirmingLogout.value = false;
confirmingLogout.value = false;
form.reset();
form.reset();
};
</script>
<template>
<ActionSection>
<template #title>
Browser Sessions
</template>
<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">
<LogOut size="18" />
<CardTitle>Aktivne prijave</CardTitle>
</div>
<CardDescription>
Upravljanje in izpis aktivnih prijav no drugih brskalnikih in napravah.
</CardDescription>
</template>
<!-- Other Browser Sessions -->
<div v-if="sessions && sessions.length > 0" class="space-y-4">
<div
v-for="(session, i) in sessions"
:key="i"
class="flex items-center gap-3 rounded-lg border p-3"
>
<div class="shrink-0">
<Monitor
v-if="session.agent.is_desktop"
class="h-8 w-8 text-muted-foreground"
/>
<Smartphone v-else class="h-8 w-8 text-muted-foreground" />
</div>
<template #description>
Manage and log out your active sessions on other browsers and devices.
</template>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium">
{{ session.agent.platform ? session.agent.platform : "Unknown" }} -
{{ session.agent.browser ? session.agent.browser : "Unknown" }}
</div>
<div class="text-xs text-muted-foreground mt-1">
{{ session.ip_address }}
<span
v-if="session.is_current_device"
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
>
Ta naprava
</span>
<span v-else class="ml-1"> · Aktiven {{ session.last_active }} </span>
</div>
</div>
</div>
</div>
<template #content>
<div class="max-w-xl text-sm text-gray-600">
If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.
</div>
<!-- Empty State -->
<div v-else class="rounded-lg border border-dashed p-8 text-center">
<Monitor class="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p class="text-sm text-muted-foreground">Najdena nobena odprta prijava.</p>
</div>
<!-- Other Browser Sessions -->
<div v-if="sessions.length > 0" class="mt-5 space-y-6">
<div v-for="(session, i) in sessions" :key="i" class="flex items-center">
<div>
<svg v-if="session.agent.is_desktop" class="w-8 h-8 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
<template #footer>
<div class="flex flex-row gap-1 items-center justify-end w-full">
<Button @click="confirmLogout">
<LogOut class="h-4 w-4 mr-2" />
Log Out Other Browser Sessions
</Button>
<svg v-else class="w-8 h-8 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
</div>
<div
v-if="form.recentlySuccessful"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<CheckCircle class="h-4 w-4 text-green-600" />
<span>Done.</span>
</div>
</div>
</template>
</AppCard>
<div class="ms-3">
<div class="text-sm text-gray-600">
{{ session.agent.platform ? session.agent.platform : 'Unknown' }} - {{ session.agent.browser ? session.agent.browser : 'Unknown' }}
</div>
<!-- Log Out Other Devices Confirmation Dialog -->
<Dialog :open="confirmingLogout" @update:open="closeModal">
<DialogContent>
<DialogHeader>
<DialogTitle>Log Out Other Browser Sessions</DialogTitle>
<DialogDescription>
Please enter your password to confirm you would like to log out of your other
browser sessions across all of your devices.
</DialogDescription>
</DialogHeader>
<div>
<div class="text-xs text-gray-500">
{{ session.ip_address }},
<div class="py-4">
<Input
ref="passwordInput"
v-model="form.password"
type="password"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="logoutOtherBrowserSessions"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<span v-if="session.is_current_device" class="text-green-500 font-semibold">This device</span>
<span v-else>Last active {{ session.last_active }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center mt-5">
<PrimaryButton @click="confirmLogout">
Log Out Other Browser Sessions
</PrimaryButton>
<ActionMessage :on="form.recentlySuccessful" class="ms-3">
Done.
</ActionMessage>
</div>
<!-- Log Out Other Devices Confirmation Modal -->
<DialogModal :show="confirmingLogout" @close="closeModal">
<template #title>
Log Out Other Browser Sessions
</template>
<template #content>
Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.
<div class="mt-4">
<TextInput
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="logoutOtherBrowserSessions"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="logoutOtherBrowserSessions"
>
Log Out Other Browser Sessions
</PrimaryButton>
</template>
</DialogModal>
</template>
</ActionSection>
<DialogFooter>
<Button variant="outline" @click="closeModal"> Cancel </Button>
<Button :disabled="form.processing" @click="logoutOtherBrowserSessions">
Log Out Other Browser Sessions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -1,17 +1,24 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue';
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { ref, computed, watch } from "vue";
import { router, useForm, usePage } from "@inertiajs/vue3";
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 { Badge } from "@/Components/ui/badge";
import ConfirmsPassword from "@/Components/ConfirmsPassword.vue";
import InputError from "@/Components/InputError.vue";
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
const props = defineProps({
requiresConfirmation: Boolean,
requiresConfirmation: Boolean,
});
const page = usePage();
@@ -23,231 +30,298 @@ const setupKey = ref(null);
const recoveryCodes = ref([]);
const confirmationForm = useForm({
code: '',
code: "",
});
const twoFactorEnabled = computed(
() => ! enabling.value && page.props.auth.user?.two_factor_enabled,
() => !enabling.value && page.props.auth.user?.two_factor_enabled
);
watch(twoFactorEnabled, () => {
if (! twoFactorEnabled.value) {
confirmationForm.reset();
confirmationForm.clearErrors();
}
if (!twoFactorEnabled.value) {
confirmationForm.reset();
confirmationForm.clearErrors();
}
});
const enableTwoFactorAuthentication = () => {
enabling.value = true;
enabling.value = true;
router.post(route('two-factor.enable'), {}, {
preserveScroll: true,
onSuccess: () => Promise.all([
showQrCode(),
showSetupKey(),
showRecoveryCodes(),
]),
onFinish: () => {
enabling.value = false;
confirming.value = props.requiresConfirmation;
},
});
router.post(
route("two-factor.enable"),
{},
{
preserveScroll: true,
onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
onFinish: () => {
enabling.value = false;
confirming.value = props.requiresConfirmation;
},
}
);
};
const showQrCode = () => {
return axios.get(route('two-factor.qr-code')).then(response => {
qrCode.value = response.data.svg;
});
return axios.get(route("two-factor.qr-code")).then((response) => {
qrCode.value = response.data.svg;
});
};
const showSetupKey = () => {
return axios.get(route('two-factor.secret-key')).then(response => {
setupKey.value = response.data.secretKey;
});
}
return axios.get(route("two-factor.secret-key")).then((response) => {
setupKey.value = response.data.secretKey;
});
};
const showRecoveryCodes = () => {
return axios.get(route('two-factor.recovery-codes')).then(response => {
recoveryCodes.value = response.data;
});
return axios.get(route("two-factor.recovery-codes")).then((response) => {
recoveryCodes.value = response.data;
});
};
const confirmTwoFactorAuthentication = () => {
confirmationForm.post(route('two-factor.confirm'), {
errorBag: "confirmTwoFactorAuthentication",
preserveScroll: true,
preserveState: true,
onSuccess: () => {
confirming.value = false;
qrCode.value = null;
setupKey.value = null;
},
});
confirmationForm.post(route("two-factor.confirm"), {
errorBag: "confirmTwoFactorAuthentication",
preserveScroll: true,
preserveState: true,
onSuccess: () => {
confirming.value = false;
qrCode.value = null;
setupKey.value = null;
},
});
};
const regenerateRecoveryCodes = () => {
axios
.post(route('two-factor.recovery-codes'))
.then(() => showRecoveryCodes());
axios.post(route("two-factor.recovery-codes")).then(() => showRecoveryCodes());
};
const disableTwoFactorAuthentication = () => {
disabling.value = true;
disabling.value = true;
router.delete(route('two-factor.disable'), {
preserveScroll: true,
onSuccess: () => {
disabling.value = false;
confirming.value = false;
},
});
router.delete(route("two-factor.disable"), {
preserveScroll: true,
onSuccess: () => {
disabling.value = false;
confirming.value = false;
},
});
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy:", err);
}
};
</script>
<template>
<ActionSection>
<template #title>
Two Factor Authentication
</template>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2">
<Shield size="18" />
<CardTitle>Dvonivojska overitev</CardTitle>
</div>
<CardDescription>
Dodatna varnost za vaš račun z dvonivojsko overitvijo.
</CardDescription>
</template>
<template #description>
Add additional security to your account using two factor authentication.
</template>
<!-- Status Header -->
<div class="flex items-start gap-3">
<div class="flex-1">
<h3
v-if="twoFactorEnabled && !confirming"
class="text-lg font-semibold flex items-center gap-2"
>
<CheckCircle class="h-5 w-5 text-green-600" />
Dvonivojska overitev omogočena
</h3>
<h3
v-else-if="twoFactorEnabled && confirming"
class="text-lg font-semibold flex items-center gap-2"
>
<AlertCircle class="h-5 w-5 text-amber-600" />
<template #content>
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-medium text-gray-900">
You have enabled two factor authentication.
</h3>
Dokončaj namestitev dvonivojske overitve
</h3>
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
Dvonivojska overitev onemogočena
</h3>
</div>
</div>
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-medium text-gray-900">
Finish enabling two factor authentication.
</h3>
<!-- QR Code & Setup -->
<div v-if="twoFactorEnabled" class="space-y-6">
<div v-if="qrCode" class="space-y-4">
<div class="rounded-lg border bg-muted/50 p-4">
<p v-if="confirming" class="text-sm font-medium mb-4">
Za dokončanje omogočanja dvostopenjske overitve skenirajte naslednjo QR-kodo z
aplikacijo za preverjanje pristnosti na vašem telefonu ali vnesite
namestitveno kodo in vpišite ustvarjeno OTP-kodo.
</p>
<p v-else class="text-sm text-muted-foreground mb-4">
Dvonivojska overitev je zdaj omogočena. Skenirajte QR kodo z aplikacijo za
preverjanje pristnosti na vašem telefonu ali vnesite namestitveni ključ.
</p>
<h3 v-else class="text-lg font-medium text-gray-900">
You have not enabled two factor authentication.
</h3>
<!-- QR Code -->
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
<div class="mt-3 max-w-xl text-sm text-gray-600">
<p>
When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.
</p>
<!-- Setup Key -->
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border">
<div class="flex items-center justify-between gap-2">
<div class="flex-1">
<Label class="text-xs text-muted-foreground">Namestitveni Ključ</Label>
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
@click="copyToClipboard(setupKey)"
>
<Copy class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div v-if="twoFactorEnabled">
<div v-if="qrCode">
<div class="mt-4 max-w-xl text-sm text-gray-600">
<p v-if="confirming" class="font-semibold">
To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.
</p>
<!-- Confirmation Code Input -->
<div v-if="confirming" class="space-y-2">
<Label for="code">Potrdite kodo</Label>
<Input
id="code"
v-model="confirmationForm.code"
type="text"
name="code"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
placeholder="Enter 6-digit code"
class="max-w-xs"
@keyup.enter="confirmTwoFactorAuthentication"
/>
<InputError :message="confirmationForm.errors.code" class="mt-2" />
</div>
</div>
<p v-else>
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
</p>
</div>
<!-- Recovery Codes -->
<div v-if="recoveryCodes.length > 0 && !confirming" class="space-y-4">
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"
>
<div class="flex items-start gap-2">
<Key
class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
/>
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
Shranite to obnovitveno kodo v upravitelja gesel. Lahko se uporabi za obnovo
vstopa v vaš račun, če se izgubi naprava z dvostopenjskim overjanjem.
</p>
</div>
</div>
<div class="mt-4 p-2 inline-block bg-white" v-html="qrCode" />
<div v-if="setupKey" class="mt-4 max-w-xl text-sm text-gray-600">
<p class="font-semibold">
Setup Key: <span v-html="setupKey"></span>
</p>
</div>
<div v-if="confirming" class="mt-4">
<InputLabel for="code" value="Code" />
<TextInput
id="code"
v-model="confirmationForm.code"
type="text"
name="code"
class="block mt-1 w-1/2"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
@keyup.enter="confirmTwoFactorAuthentication"
/>
<InputError :message="confirmationForm.errors.code" class="mt-2" />
</div>
</div>
<div v-if="recoveryCodes.length > 0 && ! confirming">
<div class="mt-4 max-w-xl text-sm text-gray-600">
<p class="font-semibold">
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.
</p>
</div>
<div class="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
<div v-for="code in recoveryCodes" :key="code">
{{ code }}
</div>
</div>
</div>
<div class="rounded-lg border bg-muted p-4">
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
<div
v-for="code in recoveryCodes"
:key="code"
class="flex items-center justify-between p-2 bg-background rounded border"
>
<span>{{ code }}</span>
<Button
type="button"
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
@click="copyToClipboard(code)"
>
<Copy class="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<!-- Action Buttons -->
<div class="flex flex-row gap-2 items-center justify-end w-full">
<!-- Enable -->
<div v-if="!twoFactorEnabled">
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
<Button type="button" :disabled="enabling">
<Shield class="h-4 w-4 mr-2" />
Enable
</Button>
</ConfirmsPassword>
</div>
<div class="mt-5">
<div v-if="! twoFactorEnabled">
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
<PrimaryButton type="button" :class="{ 'opacity-25': enabling }" :disabled="enabling">
Enable
</PrimaryButton>
</ConfirmsPassword>
</div>
<!-- Confirm -->
<template v-else>
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
<Button v-if="confirming" type="button" :disabled="enabling">
<CheckCircle class="h-4 w-4 mr-2" />
Confirm
</Button>
</ConfirmsPassword>
<div v-else>
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
<PrimaryButton
v-if="confirming"
type="button"
class="me-3"
:class="{ 'opacity-25': enabling }"
:disabled="enabling"
>
Confirm
</PrimaryButton>
</ConfirmsPassword>
<!-- Regenerate Recovery Codes -->
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
<Button
v-if="recoveryCodes.length > 0 && !confirming"
type="button"
variant="outline"
>
<RefreshCw class="h-4 w-4 mr-2" />
Regenerate Recovery Codes
</Button>
</ConfirmsPassword>
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
<SecondaryButton
v-if="recoveryCodes.length > 0 && ! confirming"
class="me-3"
>
Regenerate Recovery Codes
</SecondaryButton>
</ConfirmsPassword>
<!-- Show Recovery Codes -->
<ConfirmsPassword @confirmed="showRecoveryCodes">
<Button
v-if="recoveryCodes.length === 0 && !confirming"
type="button"
variant="outline"
>
<Key class="h-4 w-4 mr-2" />
Show Recovery Codes
</Button>
</ConfirmsPassword>
<ConfirmsPassword @confirmed="showRecoveryCodes">
<SecondaryButton
v-if="recoveryCodes.length === 0 && ! confirming"
class="me-3"
>
Show Recovery Codes
</SecondaryButton>
</ConfirmsPassword>
<!-- Cancel/Disable -->
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button
v-if="confirming"
type="button"
variant="outline"
:disabled="disabling"
>
Cancel
</Button>
</ConfirmsPassword>
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<SecondaryButton
v-if="confirming"
:class="{ 'opacity-25': disabling }"
:disabled="disabling"
>
Cancel
</SecondaryButton>
</ConfirmsPassword>
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<DangerButton
v-if="! confirming"
:class="{ 'opacity-25': disabling }"
:disabled="disabling"
>
Disable
</DangerButton>
</ConfirmsPassword>
</div>
</div>
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button
v-if="!confirming"
type="button"
variant="destructive"
:disabled="disabling"
>
Disable
</Button>
</ConfirmsPassword>
</template>
</ActionSection>
</div>
</template>
</AppCard>
</template>
@@ -1,100 +1,106 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import InputError from "@/Components/InputError.vue";
import { CheckCircle, Lock } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card";
const passwordInput = ref(null);
const currentPasswordInput = ref(null);
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
current_password: "",
password: "",
password_confirmation: "",
});
const updatePassword = () => {
form.put(route('user-password.update'), {
errorBag: 'updatePassword',
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) {
form.reset('password', 'password_confirmation');
passwordInput.value.focus();
}
form.put(route("user-password.update"), {
errorBag: "updatePassword",
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) {
form.reset("password", "password_confirmation");
passwordInput.value.focus();
}
if (form.errors.current_password) {
form.reset('current_password');
currentPasswordInput.value.focus();
}
},
});
if (form.errors.current_password) {
form.reset("current_password");
currentPasswordInput.value.focus();
}
},
});
};
</script>
<template>
<FormSection @submitted="updatePassword">
<template #title>
Update Password
</template>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2">
<Lock size="18" />
<CardTitle>Posodobi geslo</CardTitle>
</div>
<p class="text-sm">
Poskrbite, da vaš račun uporablja dolgo, naključno geslo za varnost.
</p>
</template>
<template #description>
Ensure your account is using a long, random password to stay secure.
</template>
<form @submit.prevent="updatePassword" class="space-y-6">
<div class="space-y-2">
<Label for="current_password">Trenutno geslo</Label>
<Input
id="current_password"
ref="currentPasswordInput"
v-model="form.current_password"
type="password"
autocomplete="current-password"
/>
<InputError :message="form.errors.current_password" class="mt-2" />
</div>
<template #form>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="current_password" value="Current Password" />
<TextInput
id="current_password"
ref="currentPasswordInput"
v-model="form.current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
/>
<InputError :message="form.errors.current_password" class="mt-2" />
</div>
<div class="space-y-2">
<Label for="password">Novo geslo</Label>
<Input
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="password" value="New Password" />
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="space-y-2">
<Label for="password_confirmation">Potrdi geslo</Label>
<Input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
autocomplete="new-password"
/>
<InputError :message="form.errors.password_confirmation" class="mt-2" />
</div>
</form>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="password_confirmation" value="Confirm Password" />
<TextInput
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError :message="form.errors.password_confirmation" class="mt-2" />
</div>
</template>
<template #actions>
<ActionMessage :on="form.recentlySuccessful" class="me-3">
Saved.
</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Save
</PrimaryButton>
</template>
</FormSection>
<template #footer>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
<span v-if="form.recentlySuccessful">Shranjeno.</span>
</div>
<Button type="submit" :disabled="form.processing"> Shrani </Button>
</div>
</template>
</AppCard>
</template>
@@ -1,23 +1,24 @@
<script setup>
import { ref } from 'vue';
import { Link, router, useForm } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { ref } from "vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Avatar, AvatarImage, AvatarFallback } from "@/Components/ui/avatar";
import InputError from "@/Components/InputError.vue";
import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card";
const props = defineProps({
user: Object,
user: Object,
});
const form = useForm({
_method: 'PUT',
name: props.user.name,
email: props.user.email,
photo: null,
_method: "PUT",
name: props.user.name,
email: props.user.email,
photo: null,
});
const verificationLinkSent = ref(null);
@@ -25,166 +26,188 @@ const photoPreview = ref(null);
const photoInput = ref(null);
const updateProfileInformation = () => {
if (photoInput.value) {
form.photo = photoInput.value.files[0];
}
if (photoInput.value) {
form.photo = photoInput.value.files[0];
}
form.post(route('user-profile-information.update'), {
errorBag: 'updateProfileInformation',
preserveScroll: true,
onSuccess: () => clearPhotoFileInput(),
});
form.post(route("user-profile-information.update"), {
errorBag: "updateProfileInformation",
preserveScroll: true,
onSuccess: () => clearPhotoFileInput(),
});
};
const sendEmailVerification = () => {
verificationLinkSent.value = true;
verificationLinkSent.value = true;
};
const selectNewPhoto = () => {
photoInput.value.click();
photoInput.value.click();
};
const updatePhotoPreview = () => {
const photo = photoInput.value.files[0];
const photo = photoInput.value.files[0];
if (! photo) return;
if (!photo) return;
const reader = new FileReader();
const reader = new FileReader();
reader.onload = (e) => {
photoPreview.value = e.target.result;
};
reader.onload = (e) => {
photoPreview.value = e.target.result;
};
reader.readAsDataURL(photo);
reader.readAsDataURL(photo);
};
const deletePhoto = () => {
router.delete(route('current-user-photo.destroy'), {
preserveScroll: true,
onSuccess: () => {
photoPreview.value = null;
clearPhotoFileInput();
},
});
router.delete(route("current-user-photo.destroy"), {
preserveScroll: true,
onSuccess: () => {
photoPreview.value = null;
clearPhotoFileInput();
},
});
};
const clearPhotoFileInput = () => {
if (photoInput.value?.value) {
photoInput.value.value = null;
}
if (photoInput.value?.value) {
photoInput.value.value = null;
}
};
</script>
<template>
<FormSection @submitted="updateProfileInformation">
<template #title>
Profile Information
</template>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2">
<User size="18" />
<CardTitle>Informacije profila</CardTitle>
</div>
<p class="text-sm">Posodobite informacije vašega profila in e-poštni naslov.</p>
</template>
<template #description>
Update your account's profile information and email address.
</template>
<form @submit.prevent="updateProfileInformation" class="space-y-6">
<!-- Profile Photo -->
<div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4">
<input
id="photo"
ref="photoInput"
type="file"
class="hidden"
accept="image/*"
@change="updatePhotoPreview"
/>
<template #form>
<!-- Profile Photo -->
<div v-if="$page.props.jetstream.managesProfilePhotos" class="col-span-6 sm:col-span-4">
<!-- Profile Photo File Input -->
<input
id="photo"
ref="photoInput"
type="file"
class="hidden"
@change="updatePhotoPreview"
<Label for="photo">Fotografija</Label>
<div class="flex items-center gap-4">
<!-- Current/Preview Photo -->
<Avatar class="h-20 w-20">
<AvatarImage v-if="photoPreview" :src="photoPreview" :alt="user.name" />
<AvatarImage v-else :src="user.profile_photo_url" :alt="user.name" />
<AvatarFallback>
<User class="h-8 w-8" />
</AvatarFallback>
</Avatar>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click.prevent="selectNewPhoto"
>
<Camera class="h-4 w-4 mr-2" />
Izberi fotografijo
</Button>
<Button
v-if="user.profile_photo_path"
type="button"
variant="outline"
size="sm"
@click.prevent="deletePhoto"
>
<Trash2 class="h-4 w-4 mr-2" />
Odstrani
</Button>
</div>
</div>
<InputError :message="form.errors.photo" class="mt-2" />
</div>
<!-- Name -->
<div class="space-y-2">
<Label for="name">Ime</Label>
<Input id="name" v-model="form.name" type="text" required autocomplete="name" />
<InputError :message="form.errors.name" class="mt-2" />
</div>
<!-- Email -->
<div class="space-y-2">
<Label for="email">E-pošta</Label>
<Input
id="email"
v-model="form.email"
type="email"
required
autocomplete="username"
/>
<InputError :message="form.errors.email" class="mt-2" />
<!-- Email Verification -->
<div
v-if="
$page.props.jetstream.hasEmailVerification && user.email_verified_at === null
"
class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950"
>
<div class="flex items-start gap-2">
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
<div class="flex-1 text-sm">
<p class="text-amber-800 dark:text-amber-200">
Vaš e-poštni naslov ni potrjen.
<Link
:href="route('verification.send')"
method="post"
as="button"
class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium"
@click.prevent="sendEmailVerification"
>
<InputLabel for="photo" value="Photo" />
<!-- Current Profile Photo -->
<div v-show="! photoPreview" class="mt-2">
<img :src="user.profile_photo_url" :alt="user.name" class="rounded-full h-20 w-20 object-cover">
</div>
<!-- New Profile Photo Preview -->
<div v-show="photoPreview" class="mt-2">
<span
class="block rounded-full w-20 h-20 bg-cover bg-no-repeat bg-center"
:style="'background-image: url(\'' + photoPreview + '\');'"
/>
</div>
<SecondaryButton class="mt-2 me-2" type="button" @click.prevent="selectNewPhoto">
Select A New Photo
</SecondaryButton>
<SecondaryButton
v-if="user.profile_photo_path"
type="button"
class="mt-2"
@click.prevent="deletePhoto"
Kliknite tukaj za ponovno pošiljanje potrditvenega e-sporočila.
</Link>
</p>
<div
v-show="verificationLinkSent"
class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400"
>
<CheckCircle class="h-4 w-4" />
<span
>Nova povezava za potrditev je bila poslana na vaš e-poštni
naslov.</span
>
Remove Photo
</SecondaryButton>
<InputError :message="form.errors.photo" class="mt-2" />
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Name -->
<div class="col-span-6 sm:col-span-4">
<InputLabel for="name" value="Name" />
<TextInput
id="name"
v-model="form.name"
type="text"
class="mt-1 block w-full"
required
autocomplete="name"
/>
<InputError :message="form.errors.name" class="mt-2" />
</div>
<!-- Email -->
<div class="col-span-6 sm:col-span-4">
<InputLabel for="email" value="Email" />
<TextInput
id="email"
v-model="form.email"
type="email"
class="mt-1 block w-full"
required
autocomplete="username"
/>
<InputError :message="form.errors.email" class="mt-2" />
<div v-if="$page.props.jetstream.hasEmailVerification && user.email_verified_at === null">
<p class="text-sm mt-2">
Your email address is unverified.
<Link
:href="route('verification.send')"
method="post"
as="button"
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click.prevent="sendEmailVerification"
>
Click here to re-send the verification email.
</Link>
</p>
<div v-show="verificationLinkSent" class="mt-2 font-medium text-sm text-green-600">
A new verification link has been sent to your email address.
</div>
</div>
</div>
</template>
<template #actions>
<ActionMessage :on="form.recentlySuccessful" class="me-3">
Saved.
</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Save
</PrimaryButton>
</template>
</FormSection>
<template #footer>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
<span v-if="form.recentlySuccessful">Shranjeno.</span>
</div>
<Button type="submit" :disabled="form.processing"> Shrani </Button>
</div>
</template>
</AppCard>
</template>
+4 -4
View File
@@ -2,7 +2,7 @@
import AppLayout from '@/Layouts/AppLayout.vue';
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
import LogoutOtherBrowserSessionsForm from '@/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import { Separator } from '@/Components/ui/separator';
import TwoFactorAuthenticationForm from '@/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue';
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
@@ -26,13 +26,13 @@ defineProps({
<div v-if="$page.props.jetstream.canUpdateProfileInformation">
<UpdateProfileInformationForm :user="$page.props.auth.user" />
<SectionBorder />
<Separator class="my-10" />
</div>
<div v-if="$page.props.jetstream.canUpdatePassword">
<UpdatePasswordForm class="mt-10 sm:mt-0" />
<SectionBorder />
<Separator class="my-10" />
</div>
<div v-if="$page.props.jetstream.canManageTwoFactorAuthentication">
@@ -41,7 +41,7 @@ defineProps({
class="mt-10 sm:mt-0"
/>
<SectionBorder />
<Separator class="my-10" />
</div>
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />

Some files were not shown because too many files have changed in this diff Show More