29 Commits

Author SHA1 Message Date
sipo 9f8e0c42ec dev changes 2026-05-10 14:32:17 +02: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
99 changed files with 12758 additions and 8109 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
+27
View File
@@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+19 -1
View File
@@ -25,7 +25,25 @@ yarn-error.log
check-*.php check-*.php
test-*.php test-*.php
fix-*.php fix-*.php
clean-*.php
mark-*.php
# Development Documentation # Development Documentation
IMPORT_*.md IMPORT_*.md
V2_*.md V2_*.md
REPORTS_*.md
DEDUPLICATION_*.md
# Docker Local Testing
docker-compose.local.yaml
docker-compose.override.yaml
.env.local
.env.docker
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+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.
@@ -12,6 +12,7 @@
use App\Models\SmsTemplate; use App\Models\SmsTemplate;
use App\Services\Contact\PhoneSelector; use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService; use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@@ -23,9 +24,11 @@ class PackageController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query() $packages = Package::query()
->latest('id') ->latest('id')
->paginate(25); ->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [ return Inertia::render('Admin/Packages/Index', [
'packages' => $packages, 'packages' => $packages,
@@ -48,6 +51,7 @@ public function create(Request $request): Response
->get(['id', 'name', 'content']); ->get(['id', 'name', 'content']);
$segments = \App\Models\Segment::query() $segments = \App\Models\Segment::query()
->where('active', true) ->where('active', true)
->where('exclude', false)
->orderBy('name') ->orderBy('name')
->get(['id', 'name']); ->get(['id', 'name']);
// Provide a lightweight list of recent clients with person names for filtering // Provide a lightweight list of recent clients with person names for filtering
@@ -319,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$request->validate([ $request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'], 'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'], 'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'], 'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'], 'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'], 'only_validated' => ['nullable', 'boolean'],
@@ -330,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
]); ]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null; $segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query() $query = Contract::query()
->with([ ->with([
'clientCase.person.phones', 'clientCase.person.phones',
'clientCase.client.person', 'clientCase.client.person',
'account', 'account',
'segments:id,name',
]) ])
->select('contracts.*') ->select('contracts.*')
->latest('contracts.id'); ->latest('contracts.id');
@@ -348,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->where('contract_segment.segment_id', '=', $segmentId) ->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true); ->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'))) { if ($q = trim((string) $request->input('q'))) {
@@ -397,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
}); });
} }
$contracts = $query->get(); $contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) { $data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person; $person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person']; $selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone']; $phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person; $clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [ return [
'id' => $contract->id, 'id' => $contract->id,
@@ -421,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'uuid' => $person?->uuid, 'uuid' => $person?->uuid,
'full_name' => $person?->full_name, 'full_name' => $person?->full_name,
], ],
'segment' => $segment,
// Stranka: the client person // Stranka: the client person
'client' => $clientPerson ? [ 'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id, 'id' => $contract->clientCase?->client?->id,
@@ -438,7 +452,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
}); });
return response()->json([ return response()->json([
'data' => $data 'data' => $data,
]); ]);
} }
@@ -1079,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. * Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
*/ */
+2 -2
View File
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%') ->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id'); ->groupBy('clients.id');
}) })
->where('clients.active', 1) //->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries // Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id') ->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) { ->leftJoin('contracts', function ($join) {
@@ -51,7 +51,7 @@ public function index(Client $client, Request $request)
return Inertia::render('Client/Index', [ return Inertia::render('Client/Index', [
'clients' => $query 'clients' => $query
->paginate($request->integer('per_page', 15)) ->paginate($request->integer('per_page', default: 100))
->withQueryString(), ->withQueryString(),
'filters' => $request->only(['search']), 'filters' => $request->only(['search']),
]); ]);
+4 -2
View File
@@ -62,7 +62,8 @@ public function index(Request $request)
$unassignedClients = $unassignedContracts->get() $unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client') ->pluck('clientCase.client')
->filter() ->filter()
->unique('id'); ->unique('id')
->values();
$assignedContracts = Contract::query() $assignedContracts = Contract::query()
@@ -98,7 +99,8 @@ public function index(Request $request)
$assignedClients = $assignedContracts->get() $assignedClients = $assignedContracts->get()
->pluck('clientCase.client') ->pluck('clientCase.client')
->filter() ->filter()
->unique('id'); ->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']); $users = User::query()->orderBy('name')->get(['id', 'name']);
+21 -8
View File
@@ -9,7 +9,6 @@
use App\Models\ImportEvent; use App\Models\ImportEvent;
use App\Models\ImportTemplate; use App\Models\ImportTemplate;
use App\Services\CsvImportService; use App\Services\CsvImportService;
use App\Services\Import\ImportServiceV2;
use App\Services\Import\ImportSimulationServiceV2; use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor; use App\Services\ImportProcessor;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -184,12 +183,13 @@ public function store(Request $request)
} }
// Kick off processing of an import - simple synchronous step for now // 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()]); $import->update(['status' => 'validating', 'started_at' => now()]);
try { try {
$result = $processor->process($import, user: $request->user()); $result = $processor->process($import, user: $request->user());
return response()->json($result); return response()->json($result);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error('Import processing failed', [ \Log::error('Import processing failed', [
@@ -197,12 +197,12 @@ public function process(Import $import, Request $request, ImportServiceV2 $proce
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
$import->update(['status' => 'failed']); $import->update(['status' => 'failed']);
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Import processing failed: ' . $e->getMessage(), 'message' => 'Import processing failed: '.$e->getMessage(),
], 500); ], 500);
} }
} }
@@ -712,8 +712,6 @@ public function simulatePayments(Import $import, Request $request)
* templates. For payments templates, payment-specific summaries/entities will be included * templates. For payments templates, payment-specific summaries/entities will be included
* automatically by the simulation service when mappings contain the payment root. * automatically by the simulation service when mappings contain the payment root.
* *
* @param Import $import
* @param Request $request
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
public function simulate(Import $import, Request $request) 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'); 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(); $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', '')); $search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', '')); $clientUuid = trim((string) $request->input('client', ''));
$clientId = null; $clientId = null;
+2 -2
View File
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
if ($template && $case) { if ($template && $case) {
$note = ''; $note = '';
if ($log->status === 'sent') { 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') { } elseif ($log->status === 'failed') {
$note = sprintf( $note = sprintf(
'Št: %s | Telo: %s | Napaka: %s', 'Tel: %s | Telo: %s | Napaka: %s',
(string) $this->to, (string) $this->to,
(string) $this->content, (string) $this->content,
'SMS ni bil poslan!' 'SMS ni bil poslan!'
+2
View File
@@ -6,10 +6,12 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model class Account extends Model
{ {
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */ /** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
+1
View File
@@ -46,6 +46,7 @@ class Person extends Model
'group_id', 'group_id',
'type_id', 'type_id',
'user_id', 'user_id',
'employer'
]; ];
protected $hidden = [ protected $hidden = [
@@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
$payload = $this->buildPayloadForAddress($address); $payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId; $payload['person_id'] = $personId;
$addressEntity = new \App\Models\Person\PersonAddress; $addressEntity = new PersonAddress;
$addressEntity->fill($payload); $addressEntity->fill($payload);
$addressEntity->save(); $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 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) ->where('address', $address)
->first(); ->first();
} }
+62 -6
View File
@@ -24,6 +24,7 @@
use App\Models\Person\PersonPhone; use App\Models\Person\PersonPhone;
use App\Models\Person\PersonType; use App\Models\Person\PersonType;
use App\Models\Person\PhoneType; use App\Models\Person\PhoneType;
use Exception;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$existing = Account::query() $existing = Account::query()
->where('contract_id', $contractId) ->where('contract_id', $contractId)
->where('reference', $reference) //->where('reference', $reference)
->where('active', 1) ->where('active', 1)
->first(); ->first();
@@ -1655,6 +1656,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$value = $acc[$field] ?? null; $value = $acc[$field] ?? null;
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) { if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
$value = $this->normalizeDecimal($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 // Convert empty string to 0 for amount fields
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) { if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
@@ -1688,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
if ($existing) { if ($existing) {
// Build non-null changes for account fields // Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// Track balance change // Track balance change - normalize in case DB has malformed data
$oldBalance = (float) ($existing->balance_amount ?? 0); $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 // Note: meta merging for contracts is handled in upsertContractChain, not here
if (! empty($changes)) { if (! empty($changes)) {
$existing->fill($changes); $existing->fill($changes);
@@ -1698,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 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)) { 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) { if ($newBalance !== $oldBalance) {
try { try {
$contractId = $existing->contract_id; $contractId = $existing->contract_id;
@@ -2974,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
// Create person if any fields present; ensure required foreign keys // Create person if any fields present; ensure required foreign keys
if (! empty($p)) { if (! empty($p)) {
$data = []; $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)) { if (array_key_exists($k, $p)) {
$data[$k] = $p[$k]; $data[$k] = $p[$k];
} }
@@ -2987,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
$data['full_name'] = trim($fn.' '.$ln); $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 // ensure required group/type ids
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId(); $data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
@@ -3163,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') { if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO'; $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 // 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) $existing = PersonAddress::where('person_id', $personId)
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces]) ->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(); ->first();
$applyInsert = []; $applyInsert = [];
@@ -3211,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$data['person_id'] = $personId; $data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO'; $data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId(); $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 { try {
$created = PersonAddress::create($data); $created = PersonAddress::create($data);
+4
View File
@@ -45,6 +45,10 @@
} }
}, },
"scripts": { "scripts": {
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve --no-reload --port=8090\" \"php artisan queue:listen --tries=1\" \"npm run dev\" --names='server,queue,vite'"
],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi"
+1 -1
View File
@@ -60,7 +60,7 @@
'features' => [ 'features' => [
// Features::termsAndPrivacyPolicy(), // Features::termsAndPrivacyPolicy(),
// Features::profilePhotos(), // Features::profilePhotos(),
Features::api(), // Features::api(),
// Features::teams(['invitations' => true]), // Features::teams(['invitations' => true]),
Features::accountDeletion(), 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', 'key' => 'person',
'canonical_root' => 'person', 'canonical_root' => 'person',
'label' => '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' => [ 'field_aliases' => [
'dob' => 'birthday', 'dob' => 'birthday',
'date_of_birth' => 'birthday', 'date_of_birth' => 'birthday',
@@ -30,6 +30,7 @@ public function run(): void
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'], ['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' => '/^(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' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
], ],
'ui' => ['order' => 1], '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
+19
View File
@@ -0,0 +1,19 @@
// @ts-check
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
+6102 -6056
View File
File diff suppressed because it is too large Load Diff
+67 -66
View File
@@ -1,68 +1,69 @@
{ {
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json" "typecheck": "vue-tsc --noEmit -p tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@inertiajs/vue3": "2.0", "@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10", "@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.10.3", "@tailwindcss/typography": "^0.5.19",
"@vitejs/plugin-vue": "^6.0.2", "@types/node": "^24.10.3",
"autoprefixer": "^10.4.22", "@vitejs/plugin-vue": "^6.0.2",
"axios": "^1.13.2", "autoprefixer": "^10.4.22",
"laravel-vite-plugin": "^2.0.1", "axios": "^1.13.2",
"postcss": "^8.5.6", "laravel-vite-plugin": "^2.0.1",
"tailwindcss": "^4.1.16", "postcss": "^8.5.6",
"typescript": "^5.9.3", "tailwindcss": "^4.1.16",
"vite": "^7.2.7", "typescript": "^5.9.3",
"vue": "^3.3.13", "vite": "^7.2.7",
"vue-tsc": "^3.1.8" "vue": "^3.3.13",
}, "vue-tsc": "^3.1.8"
"dependencies": { },
"@fortawesome/fontawesome-svg-core": "^6.7.2", "dependencies": {
"@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.1.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@guolao/vue-monaco-editor": "^1.6.0", "@fortawesome/vue-fontawesome": "^3.1.2",
"@headlessui/vue": "^1.7.23", "@guolao/vue-monaco-editor": "^1.6.0",
"@heroicons/vue": "^2.2.0", "@headlessui/vue": "^1.7.23",
"@internationalized/date": "^3.10.0", "@heroicons/vue": "^2.2.0",
"@tanstack/vue-table": "^8.21.3", "@internationalized/date": "^3.10.0",
"@unovis/ts": "^1.6.2", "@tanstack/vue-table": "^8.21.3",
"@unovis/vue": "^1.6.2", "@unovis/ts": "^1.6.2",
"@vee-validate/zod": "^4.15.1", "@unovis/vue": "^1.6.2",
"@vuepic/vue-datepicker": "^11.0.3", "@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.1.0", "@vuepic/vue-datepicker": "^11.0.3",
"apexcharts": "^4.7.0", "@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1", "apexcharts": "^4.7.0",
"clean": "^4.0.2", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clean": "^4.0.2",
"date-fns": "^4.1.0", "clsx": "^2.1.1",
"lodash": "^4.17.21", "date-fns": "^4.1.0",
"lucide-vue-next": "^0.552.0", "lodash": "^4.17.21",
"material-design-icons-iconfont": "^6.7.0", "lucide-vue-next": "^0.552.0",
"monaco-editor": "^0.55.1", "material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0", "monaco-editor": "^0.55.1",
"quill": "^1.3.7", "preline": "^2.7.0",
"reka-ui": "^2.7.0", "quill": "^1.3.7",
"tailwind-merge": "^3.4.0", "reka-ui": "^2.7.0",
"tailwindcss-animate": "^1.0.7", "tailwind-merge": "^3.4.0",
"tailwindcss-inner-border": "^0.2.0", "tailwindcss-animate": "^1.0.7",
"v-calendar": "^3.1.2", "tailwindcss-inner-border": "^0.2.0",
"vee-validate": "^4.15.1", "v-calendar": "^3.1.2",
"vue-currency-input": "^3.2.1", "vee-validate": "^4.15.1",
"vue-multiselect": "^3.4.0", "vue-currency-input": "^3.2.1",
"vue-search-input": "^1.1.19", "vue-multiselect": "^3.4.0",
"vue-sonner": "^2.0.9", "vue-search-input": "^1.1.19",
"vue3-apexcharts": "^1.10.0", "vue-sonner": "^2.0.9",
"vuedraggable": "^4.1.0", "vue3-apexcharts": "^1.10.0",
"zod": "^3.25.76" "vuedraggable": "^4.1.0",
} "zod": "^3.25.76"
}
} }
+81
View File
@@ -0,0 +1,81 @@
// @ts-check
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
+88 -88
View File
@@ -1,118 +1,118 @@
<script setup> <script setup>
import { ref, reactive, nextTick } from 'vue'; import { ref, reactive, nextTick } from "vue";
import DialogModal from './DialogModal.vue'; import DialogModal from "./DialogModal.vue";
import InputError from './InputError.vue'; import InputError from "./InputError.vue";
import PrimaryButton from './PrimaryButton.vue'; import PrimaryButton from "./PrimaryButton.vue";
import SecondaryButton from './SecondaryButton.vue'; import SecondaryButton from "./SecondaryButton.vue";
import TextInput from './TextInput.vue'; import { Input } from "@/Components/ui/input";
const emit = defineEmits(['confirmed']); const emit = defineEmits(["confirmed"]);
defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: 'Confirm Password', default: "Confirm Password",
}, },
content: { content: {
type: String, type: String,
default: 'For your security, please confirm your password to continue.', default: "For your security, please confirm your password to continue.",
}, },
button: { button: {
type: String, type: String,
default: 'Confirm', default: "Confirm",
}, },
}); });
const confirmingPassword = ref(false); const confirmingPassword = ref(false);
const form = reactive({ const form = reactive({
password: '', password: "",
error: '', error: "",
processing: false, processing: false,
}); });
const passwordInput = ref(null); const passwordInput = ref(null);
const startConfirmingPassword = () => { const startConfirmingPassword = () => {
axios.get(route('password.confirmation')).then(response => { axios.get(route("password.confirmation")).then((response) => {
if (response.data.confirmed) { if (response.data.confirmed) {
emit('confirmed'); emit("confirmed");
} else { } else {
confirmingPassword.value = true; confirmingPassword.value = true;
setTimeout(() => passwordInput.value.focus(), 250); setTimeout(() => passwordInput.value.focus(), 250);
} }
}); });
}; };
const confirmPassword = () => { const confirmPassword = () => {
form.processing = true; form.processing = true;
axios.post(route('password.confirm'), { axios
password: form.password, .post(route("password.confirm"), {
}).then(() => { password: form.password,
form.processing = false; })
.then(() => {
form.processing = false;
closeModal(); closeModal();
nextTick().then(() => emit('confirmed')); nextTick().then(() => emit("confirmed"));
})
}).catch(error => { .catch((error) => {
form.processing = false; form.processing = false;
form.error = error.response.data.errors.password[0]; form.error = error.response.data.errors.password[0];
passwordInput.value.focus(); passwordInput.value.focus();
}); });
}; };
const closeModal = () => { const closeModal = () => {
confirmingPassword.value = false; confirmingPassword.value = false;
form.password = ''; form.password = "";
form.error = ''; form.error = "";
}; };
</script> </script>
<template> <template>
<span> <span>
<span @click="startConfirmingPassword"> <span @click="startConfirmingPassword">
<slot /> <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>
<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> </template>
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent :class="maxWidthClass"> <DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -6,34 +6,40 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/Components/ui/dialog'; } from "@/Components/ui/dialog";
import { Button } from '@/Components/ui/button'; import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
title: { type: String, default: 'Izbriši' }, title: { type: String, default: "Izbriši" },
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' }, message: {
confirmText: { type: String, default: 'Izbriši' }, type: String,
cancelText: { type: String, default: 'Prekliči' }, 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 }, processing: { type: Boolean, default: false },
itemName: { type: String, default: null }, // Optional name to show in confirmation 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); const open = ref(props.show);
watch(() => props.show, (newVal) => { watch(
open.value = newVal; () => props.show,
}); (newVal) => {
open.value = newVal;
}
);
watch(open, (newVal) => { watch(open, (newVal) => {
emit('update:show', newVal); emit("update:show", newVal);
if (!newVal) { if (!newVal) {
emit('close'); emit("close");
} }
}); });
@@ -42,7 +48,7 @@ const onClose = () => {
}; };
const onConfirm = () => { const onConfirm = () => {
emit('confirm'); emit("confirm");
}; };
</script> </script>
@@ -59,8 +65,13 @@ const onConfirm = () => {
<DialogDescription> <DialogDescription>
<div class="flex items-start gap-4 pt-4"> <div class="flex items-start gap-4 pt-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" /> 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> </div>
<div class="flex-1 space-y-2"> <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"> <p v-if="itemName" class="text-sm font-medium text-gray-900">
{{ itemName }} {{ itemName }}
</p> </p>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
Ta dejanje ni mogoče razveljaviti.
</p>
</div> </div>
</div> </div>
</DialogDescription> </DialogDescription>
@@ -82,15 +91,10 @@ const onConfirm = () => {
<Button variant="outline" @click="onClose" :disabled="processing"> <Button variant="outline" @click="onClose" :disabled="processing">
{{ cancelText }} {{ cancelText }}
</Button> </Button>
<Button <Button variant="destructive" @click="onConfirm" :disabled="processing">
variant="destructive"
@click="onConfirm"
:disabled="processing"
>
{{ confirmText }} {{ confirmText }}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent :class="maxWidthClass"> <DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -1,15 +1,27 @@
<script setup> <script setup>
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue' import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import { useForm } from 'vee-validate' import { useForm } from "vee-validate";
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from "@vee-validate/zod";
import * as z from 'zod' import * as z from "zod";
import { ref, watch } from 'vue' import { ref, watch } from "vue";
import { router } from '@inertiajs/vue3' import { router } from "@inertiajs/vue3";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form' import {
import { Input } from '@/Components/ui/input' FormControl,
import { Textarea } from '@/Components/ui/textarea' FormField,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select' FormItem,
import { Switch } from '@/Components/ui/switch' 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({ const props = defineProps({
show: { type: Boolean, default: false }, 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 // Optional list of contracts to allow attaching the document directly to a contract
// Each item should have at least: { uuid, reference } // Each item should have at least: { uuid, reference }
contracts: { type: Array, default: () => [] }, contracts: { type: Array, default: () => [] },
}) });
const emit = defineEmits(['close', 'uploaded']) const emit = defineEmits(["close", "uploaded"]);
const MAX_SIZE = 25 * 1024 * 1024 // 25MB const MAX_SIZE = 25 * 1024 * 1024; // 25MB
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png'] const ALLOWED_EXTS = [
"doc",
"docx",
"pdf",
"txt",
"csv",
"xls",
"xlsx",
"jpeg",
"jpg",
"png",
];
const formSchema = toTypedSchema(z.object({ const formSchema = toTypedSchema(
name: z.string().min(1, 'Ime je obvezno'), z.object({
description: z.string().optional(), name: z.string().min(1, "Ime je obvezno"),
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'), description: z.string().optional(),
is_public: z.boolean().default(true), file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
contract_uuid: z.string().nullable().optional(), is_public: z.boolean().default(true),
})) contract_uuid: z.string().nullable().optional(),
})
);
const form = useForm({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
initialValues: { initialValues: {
name: '', name: "",
description: '', description: "",
file: null, file: null,
is_public: true, is_public: true,
contract_uuid: null, contract_uuid: null,
}, },
}) });
const localError = ref('') const localError = ref("");
watch(() => props.show, (v) => { watch(
if (!v) return () => props.show,
localError.value = '' (v) => {
form.resetForm() if (!v) return;
}) localError.value = "";
form.resetForm();
}
);
const onFileChange = (e) => { const onFileChange = (e) => {
localError.value = '' localError.value = "";
const f = e.target.files?.[0] const f = e.target.files?.[0];
if (!f) { if (!f) {
form.setFieldValue('file', null) form.setFieldValue("file", null);
return return;
} }
const ext = (f.name.split('.').pop() || '').toLowerCase() const ext = (f.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) { if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ') localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
e.target.value = '' e.target.value = "";
form.setFieldValue('file', null) form.setFieldValue("file", null);
return return;
} }
if (f.size > MAX_SIZE) { if (f.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.' localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
e.target.value = '' e.target.value = "";
form.setFieldValue('file', null) form.setFieldValue("file", null);
return return;
} }
form.setFieldValue('file', f) form.setFieldValue("file", f);
if (!form.values.name) { if (!form.values.name) {
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, '')) form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
} }
} };
const submit = form.handleSubmit(async (values) => { const submit = form.handleSubmit(async (values) => {
localError.value = '' localError.value = "";
if (!values.file) { if (!values.file) {
localError.value = 'Prosimo izberite datoteko.' localError.value = "Prosimo izberite datoteko.";
return return;
} }
const ext = (values.file.name.split('.').pop() || '').toLowerCase() const ext = (values.file.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) { if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ') localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
return return;
} }
if (values.file.size > MAX_SIZE) { if (values.file.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.' localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
return return;
} }
const formData = new FormData() const formData = new FormData();
formData.append('name', values.name) formData.append("name", values.name);
formData.append('description', values.description || '') formData.append("description", values.description || "");
formData.append('file', values.file) formData.append("file", values.file);
formData.append('is_public', values.is_public ? '1' : '0') formData.append("is_public", values.is_public ? "1" : "0");
if (values.contract_uuid) { if (values.contract_uuid) {
formData.append('contract_uuid', values.contract_uuid) formData.append("contract_uuid", values.contract_uuid);
} }
router.post(props.postUrl, formData, { router.post(props.postUrl, formData, {
forceFormData: true, forceFormData: true,
onSuccess: () => { onSuccess: () => {
emit('uploaded') emit("uploaded");
emit('close') emit("close");
form.resetForm() form.resetForm();
}, },
onError: (errors) => { onError: (errors) => {
// Set form errors if any // Set form errors if any
if (errors.name) form.setFieldError('name', errors.name) if (errors.name) form.setFieldError("name", errors.name);
if (errors.description) form.setFieldError('description', errors.description) if (errors.description) form.setFieldError("description", errors.description);
if (errors.file) form.setFieldError('file', errors.file) if (errors.file) form.setFieldError("file", errors.file);
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid) if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
}, },
}) });
}) });
const close = () => emit('close') const close = () => emit("close");
const onConfirm = () => { const onConfirm = () => {
submit() submit();
} };
</script> </script>
<template> <template>
@@ -137,7 +165,11 @@ const onConfirm = () => {
@confirm="onConfirm" @confirm="onConfirm"
> >
<form @submit.prevent="submit" class="space-y-4"> <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> <FormItem>
<FormLabel>Pripiši k</FormLabel> <FormLabel>Pripiši k</FormLabel>
<Select :model-value="value" @update:model-value="handleChange"> <Select :model-value="value" @update:model-value="handleChange">
@@ -148,11 +180,7 @@ const onConfirm = () => {
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem :value="null">Primer</SelectItem> <SelectItem :value="null">Primer</SelectItem>
<SelectItem <SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
v-for="c in props.contracts"
:key="c.uuid"
:value="c.uuid"
>
Pogodba: {{ c.reference }} Pogodba: {{ c.reference }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -165,7 +193,11 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Ime</FormLabel> <FormLabel>Ime</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -184,29 +216,24 @@ const onConfirm = () => {
<FormField v-slot="{ value, handleChange }" name="file"> <FormField v-slot="{ value, handleChange }" name="file">
<FormItem> <FormItem>
<FormLabel>Datoteka (max 25MB)</FormLabel> <FormLabel>Datoteka (max 25MB)</FormLabel>
<FormControl> <FormControl class="flex w-full">
<Input <Input
id="doc_file" id="doc_file"
type="file" type="file"
@change="onFileChange" @change="onFileChange"
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png" accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
class="min-w-0 w-full"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div> <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> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ value, handleChange }" name="is_public"> <FormField v-slot="{ value, handleChange }" name="is_public">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Switch <Switch :model-value="value" @update:model-value="handleChange" />
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">
<FormLabel>Javno</FormLabel> <FormLabel>Javno</FormLabel>
@@ -1,30 +1,219 @@
<script setup> <script setup>
import { ref, computed, watch } from "vue";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/Components/ui/dialog' } from "@/Components/ui/dialog";
import { Button } from '@/Components/ui/button' import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
src: { type: String, default: '' }, src: { type: String, default: "" },
title: { type: String, default: 'Dokument' } title: { type: String, default: "Dokument" },
}) mimeType: { type: String, default: "" },
const emit = defineEmits(['close']) 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> </script>
<template> <template>
<Dialog :open="show" @update:open="(open) => !open && $emit('close')"> <Dialog :open="show" @update:open="(open) => !open && $emit('close')">
<DialogContent class="max-w-4xl"> <DialogContent class="max-w-full xl:max-w-7xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle> <DialogTitle>
{{ title }}
</DialogTitle>
<DialogDescription>
<Badge>
{{ fileExtension }}
</Badge>
</DialogDescription>
</DialogHeader> </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 v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
</div> </div>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button> <Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
</div> </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 CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -97,7 +92,7 @@ watch(
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", 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 || "", description: a.description || "",
}); });
return; return;
@@ -108,52 +103,51 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(() => props.show, (val) => { watch(
if (val && props.edit && props.id) { () => props.show,
const a = props.person.addresses?.find((x) => x.id === props.id); (val) => {
if (a) { if (val && props.edit && props.id) {
form.setValues({ const a = props.person.addresses?.find((x) => x.id === props.id);
address: a.address || "", if (a) {
country: a.country || "", form.setValues({
post_code: a.post_code || a.postal_code || "", address: a.address || "",
city: a.city || "", country: a.country || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), post_code: a.post_code || a.postal_code || "",
description: a.description || "", 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 () => { const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const { values } = form;
router.post( router.post(route("person.address.create", props.person), values, {
route("person.address.create", props.person), preserveScroll: true,
values, onSuccess: () => {
{ processing.value = false;
preserveScroll: true, close();
onSuccess: () => { resetForm();
processing.value = false; },
close(); onError: (errors) => {
resetForm(); Object.keys(errors).forEach((field) => {
}, const errorMessages = Array.isArray(errors[field])
onError: (errors) => { ? errors[field]
Object.keys(errors).forEach((field) => { : [errors[field]];
const errorMessages = Array.isArray(errors[field]) form.setFieldError(field, errorMessages[0]);
? errors[field] });
: [errors[field]]; processing.value = false;
form.setFieldError(field, errorMessages[0]); },
}); onFinish: () => {
processing.value = false; processing.value = false;
}, },
onFinish: () => { });
processing.value = false;
},
}
);
}; };
const update = async () => { const update = async () => {
@@ -223,7 +217,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Naslov</FormLabel> <FormLabel>Naslov</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" /> <Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -233,7 +232,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Država</FormLabel> <FormLabel>Država</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" /> <Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -243,7 +247,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Poštna številka</FormLabel> <FormLabel>Poštna številka</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -253,7 +262,22 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Mesto</FormLabel> <FormLabel>Mesto</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -85,7 +80,7 @@ const hydrate = () => {
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", 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 || "", description: a.description || "",
}); });
return; return;
@@ -94,10 +89,17 @@ const hydrate = () => {
resetForm(); resetForm();
}; };
watch(() => props.id, () => hydrate(), { immediate: true }); watch(
watch(() => props.show, (v) => { () => props.id,
if (v) hydrate(); () => hydrate(),
}); { immediate: true }
);
watch(
() => props.show,
(v) => {
if (v) hydrate();
}
);
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
@@ -157,7 +159,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Naslov</FormLabel> <FormLabel>Naslov</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" /> <Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -167,7 +174,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Država</FormLabel> <FormLabel>Država</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" /> <Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -177,7 +189,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Poštna številka</FormLabel> <FormLabel>Poštna številka</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -187,7 +204,22 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Mesto</FormLabel> <FormLabel>Mesto</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <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"> <Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" 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> </DropdownMenu>
</div> </div>
</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.post_code && address.city
? `${address.address}, ${address.post_code} ${address.city}` ? `${address.address}, ${address.post_code} ${address.city}`
: address.address : address.address
}} }}
</p> </p>
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
{{ address.description }}
</p>
</Card> </Card>
<button <button
v-if="edit" v-if="edit"
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getEmails(person).length"> <template v-if="getEmails(person).length">
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx"> <Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit"> <div class="flex items-center justify-between" v-if="edit">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
v-if="email?.label" 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" 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> </div>
<div class="p-1"> <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 || "-" }} {{ email?.value || email?.email || email?.address || "-" }}
</p> </p>
<p <p
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
<template> <template>
<Tabs v-model="activeTab" class="mt-2"> <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 <TabsTrigger
value="person" value="person"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2" 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> </TabsList>
<TabsContent value="person" class="py-2"> <TabsContent value="person" class="py-2">
<PersonInfoPersonTab <PersonInfoPersonTab
:is-client-case="clientCaseUuid ? true : false"
:person="person" :person="person"
:edit="edit" :edit="edit"
:person-edit="personEdit" :person-edit="personEdit"
@@ -1,14 +1,16 @@
<script setup> <script setup>
import { UserEditIcon } from "@/Utilities/Icons"; import { UserEditIcon } from "@/Utilities/Icons";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({ const props = defineProps({
person: Object, person: Object,
isClientCase: { type: Boolean, default: false },
edit: { type: Boolean, default: true }, edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true }, personEdit: { type: Boolean, default: true },
}); });
const emit = defineEmits(['edit']); const emit = defineEmits(["edit"]);
const getMainAddress = (adresses) => { const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? ""; const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
}; };
const handleEdit = () => { const handleEdit = () => {
emit('edit'); emit("edit");
}; };
</script> </script>
@@ -44,51 +46,126 @@ const handleEdit = () => {
> >
<UserEditIcon size="md" /> <UserEditIcon size="md" />
<span>Uredi</span> <span>Uredi</span>
</button> </Button>
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3"> <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"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p> 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> <p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p> 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"> <p class="text-sm font-semibold text-gray-900">
{{ person.full_name }} {{ person.full_name }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p> 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"> <p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }} {{ person.tax_number }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p> 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"> <p class="text-sm font-semibold text-gray-900">
{{ person.social_security_number }} {{ person.social_security_number }}
</p> </p>
</div> </div>
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"> <div
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> v-if="isClientCase"
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p> 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"> <p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }} {{ getMainAddress(person.addresses) }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p> 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"> <p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }} {{ getMainPhone(person.phones) }}
</p> </p>
</div> </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"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p> 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"> <p class="text-sm font-medium text-gray-900">
{{ person.description }} {{ person.description }}
</p> </p>
</div> </div>
</div> </div>
</template> </template>
@@ -8,7 +8,13 @@ import {
} from "@/Components/ui/dropdown-menu"; } from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card"; import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button"; 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"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({ const props = defineProps({
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getPhones(person).length"> <template v-if="getPhones(person).length">
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id"> <Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" 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> </DropdownMenu>
</div> </div>
</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 }} {{ 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> </p>
</Card> </Card>
</template> </template>
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
import axios from "axios";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -301,27 +302,13 @@ const updateSmsFromSelection = async () => {
const url = route("clientCase.sms.preview", { const url = route("clientCase.sms.preview", {
client_case: props.clientCaseUuid, client_case: props.clientCaseUuid,
}); });
const res = await fetch(url, { const { data } = await axios.post(url, {
method: "POST", template_id: form.values.template_id,
headers: { contract_uuid: form.values.contract_uuid || null,
"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",
}); });
if (res.ok) { if (typeof data?.content === "string" && data.content.trim() !== "") {
const data = await res.json(); form.setFieldValue("message", data.content);
if (typeof data?.content === "string" && data.content.trim() !== "") { return;
form.setFieldValue("message", data.content);
return;
}
} }
} catch (e) { } catch (e) {
// ignore and fallback // ignore and fallback
@@ -1,182 +1,205 @@
<script setup> <script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'; import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import SectionTitle from '@/Components/SectionTitle.vue'; import SectionTitle from "@/Components/SectionTitle.vue";
import { useForm, Field as FormField } from "vee-validate"; import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod"; import * as z from "zod";
import { router } from '@inertiajs/vue3'; import { router } from "@inertiajs/vue3";
import { ref } from 'vue'; import { ref } from "vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import DatePicker from "../DatePicker.vue";
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
person: Object person: Object,
}); });
const processingUpdate = ref(false); const processingUpdate = ref(false);
const emit = defineEmits(['close']); const emit = defineEmits(["close"]);
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z.object({ z.object({
full_name: z.string().min(1, "Naziv je obvezen."), full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(), tax_number: z.string().optional(),
social_security_number: z.string().optional(), social_security_number: z.string().optional(),
birthday: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
employer: z.string().optional(),
}) })
); );
const form = useForm({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
initialValues: { initialValues: {
full_name: props.person?.full_name || '', full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || '', tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || '', social_security_number: props.person?.social_security_number || "",
description: props.person?.description || '' birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
}, },
}); });
const close = () => { const close = () => {
emit('close'); emit("close");
setTimeout(() => { setTimeout(() => {
form.resetForm({ form.resetForm({
values: { values: {
full_name: props.person?.full_name || '', full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || '', tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || '', social_security_number: props.person?.social_security_number || "",
description: props.person?.description || '' birthday: props.person?.birthday || "",
} description: props.person?.description || "",
}); employer: props.person?.employer || "",
}, 500); },
} });
}, 500);
};
const updatePerson = async () => { const updatePerson = async () => {
processingUpdate.value = true; processingUpdate.value = true;
const { values } = form; const { values } = form;
router.put( router.put(route("person.update", props.person), values, {
route('person.update', props.person), preserveScroll: true,
values, onSuccess: () => {
{ processingUpdate.value = false;
preserveScroll: true, close();
onSuccess: () => { },
processingUpdate.value = false; onError: (errors) => {
close(); // Map Inertia errors to VeeValidate field errors
}, Object.keys(errors).forEach((field) => {
onError: (errors) => { const errorMessages = Array.isArray(errors[field])
// Map Inertia errors to VeeValidate field errors ? errors[field]
Object.keys(errors).forEach((field) => { : [errors[field]];
const errorMessages = Array.isArray(errors[field]) form.setFieldError(field, errorMessages[0]);
? errors[field] });
: [errors[field]]; processingUpdate.value = false;
form.setFieldError(field, errorMessages[0]); },
}); onFinish: () => {
processingUpdate.value = false; processingUpdate.value = false;
}, },
onFinish: () => { });
processingUpdate.value = false; };
},
}
);
}
const onSubmit = form.handleSubmit(() => { const onSubmit = form.handleSubmit(() => {
updatePerson(); updatePerson();
}); });
const onConfirm = () => { const onConfirm = () => {
onSubmit(); onSubmit();
} };
</script> </script>
<template> <template>
<UpdateDialog <UpdateDialog
:show="show" :show="show"
:title="`Posodobi ${person.full_name}`" :title="`Posodobi ${person.full_name}`"
confirm-text="Shrani" confirm-text="Shrani"
:processing="processingUpdate" :processing="processingUpdate"
@close="close" @close="close"
@confirm="onConfirm" @confirm="onConfirm"
> >
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4"> <SectionTitle class="border-b mb-4">
<template #title> <template #title> Oseba </template>
Oseba </SectionTitle>
</template>
</SectionTitle>
<div class="space-y-4"> <div class="space-y-4">
<FormField v-slot="{ componentField }" name="full_name"> <FormField v-slot="{ componentField }" name="full_name">
<FormItem> <FormItem>
<FormLabel>Naziv</FormLabel> <FormLabel>Naziv</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="cfullname" id="cfullname"
type="text" type="text"
placeholder="Naziv" placeholder="Naziv"
autocomplete="full-name" autocomplete="full-name"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="tax_number"> <FormField v-slot="{ componentField }" name="tax_number">
<FormItem> <FormItem>
<FormLabel>Davčna</FormLabel> <FormLabel>Davčna</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="ctaxnumber" id="ctaxnumber"
type="text" type="text"
placeholder="Davčna številka" placeholder="Davčna številka"
autocomplete="tax-number" autocomplete="tax-number"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="social_security_number"> <FormField v-slot="{ componentField }" name="social_security_number">
<FormItem> <FormItem>
<FormLabel>Matična / Emšo</FormLabel> <FormLabel>Matična / Emšo</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="csocialSecurityNumber" id="csocialSecurityNumber"
type="text" type="text"
placeholder="Matična / Emšo" placeholder="Matična / Emšo"
autocomplete="social-security-number" autocomplete="social-security-number"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="description"> <FormField v-slot="{ componentField }" name="employer">
<FormItem> <FormItem>
<FormLabel>Opis</FormLabel> <FormLabel>Delodajalec</FormLabel>
<FormControl> <FormControl>
<Textarea <Input
id="cdescription" id="cemployer"
placeholder="Opis" type="text"
v-bind="componentField" placeholder="Delodajalec"
/> autocomplete="employer"
</FormControl> v-bind="componentField"
<FormMessage /> />
</FormItem> </FormControl>
</FormField> <FormMessage />
</div> </FormItem>
</form> </FormField>
</UpdateDialog>
<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> </template>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue"; import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -101,29 +96,25 @@ const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const { values } = form;
router.post( router.post(route("person.phone.create", props.person), values, {
route("person.phone.create", props.person), preserveScroll: true,
values, onSuccess: () => {
{ close();
preserveScroll: true, resetForm();
onSuccess: () => { },
close(); onError: (errors) => {
resetForm(); Object.keys(errors).forEach((field) => {
}, const errorMessages = Array.isArray(errors[field])
onError: (errors) => { ? errors[field]
Object.keys(errors).forEach((field) => { : [errors[field]];
const errorMessages = Array.isArray(errors[field]) form.setFieldError(field, errorMessages[0]);
? errors[field] });
: [errors[field]]; processing.value = false;
form.setFieldError(field, errorMessages[0]); },
}); onFinish: () => {
processing.value = false; processing.value = false;
}, },
onFinish: () => { });
processing.value = false;
},
}
);
}; };
const onSubmit = form.handleSubmit(() => { const onSubmit = form.handleSubmit(() => {
@@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem> <FormItem>
<FormLabel>Številka</FormLabel> <FormLabel>Številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" /> <Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <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 }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <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 }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem> </FormItem>
</FormField> </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"> <FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -108,7 +103,7 @@ function hydrateFromProps() {
form.setValues({ form.setValues({
nu: p.nu || "", nu: p.nu || "",
country_code: p.country_code ?? 386, 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 || "", description: p.description || "",
validated: !!p.validated, validated: !!p.validated,
phone_type: p.phone_type ?? null, phone_type: p.phone_type ?? null,
@@ -119,8 +114,17 @@ function hydrateFromProps() {
resetForm(); resetForm();
} }
watch(() => props.id, () => hydrateFromProps(), { immediate: true }); watch(
watch(() => props.show, (val) => { if (val) hydrateFromProps(); }); () => props.id,
() => hydrateFromProps(),
{ immediate: true }
);
watch(
() => props.show,
(val) => {
if (val) hydrateFromProps();
}
);
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
@@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem> <FormItem>
<FormLabel>Številka</FormLabel> <FormLabel>Številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" /> <Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <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 }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <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 }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem> </FormItem>
</FormField> </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"> <FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
@@ -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>
@@ -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 }, reference: { type: null, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
const emits = defineEmits([ const emits = defineEmits([
+4 -6
View File
@@ -251,19 +251,17 @@ function isActive(patterns) {
: 'sticky top-0 h-screen overflow-y-auto', : 'sticky top-0 h-screen overflow-y-auto',
]" ]"
> >
<div <div class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar">
class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white"
>
<Link <Link
:href="route('dashboard')" :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 /> <ApplicationMark />
<span <span
v-if="!sidebarCollapsed" 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> </span>
</Link> </Link>
</div> </div>
+4 -17
View File
@@ -10,19 +10,6 @@ import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue"; import NotificationsBell from "./Partials/NotificationsBell.vue";
import ToastContainer from "@/Components/Toast/ToastContainer.vue"; import ToastContainer from "@/Components/Toast/ToastContainer.vue";
import { Button } from "@/Components/ui/button"; 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 { MenuIcon } from "lucide-vue-next";
import { SearchIcon } from "lucide-vue-next"; import { SearchIcon } from "lucide-vue-next";
import { ChevronDownIcon } from "lucide-vue-next"; import { ChevronDownIcon } from "lucide-vue-next";
@@ -310,18 +297,18 @@ function isActive(patterns) {
]" ]"
> >
<div <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 <Link
:href="route('dashboard')" :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 /> <ApplicationMark />
<span <span
v-if="!sidebarCollapsed" 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> </span>
</Link> </Link>
</div> </div>
+3 -3
View File
@@ -149,14 +149,14 @@ const closeSearch = () => (searchOpen.value = false);
> >
<Link <Link
:href="route('phone.index')" :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 /> <ApplicationMark />
<span <span
v-if="showLabels" 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> </span>
</Link> </Link>
</div> </div>
+166 -190
View File
@@ -1,8 +1,18 @@
<script setup> <script setup>
import { Input } from "@/Components/ui/input"; 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 axios from "axios";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { SearchIcon } from "@/Utilities/Icons"; import { SearchIcon, XIcon } from "lucide-vue-next";
import { onMounted, onUnmounted, ref, watch } from "vue"; import { onMounted, onUnmounted, ref, watch } from "vue";
import { Link } from "@inertiajs/vue3"; import { Link } from "@inertiajs/vue3";
@@ -55,203 +65,169 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown)); onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script> </script>
<template> <template>
<teleport to="body"> <Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
<transition name="fade"> <DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
<div v-if="isOpen" class="fixed inset-0 z-50"> <div class="p-4 border-b" ref="inputWrap">
<div <div class="relative">
class="absolute inset-0 bg-gradient-to-br from-slate-900/60 to-slate-800/60 backdrop-blur-sm" <SearchIcon
@click="isOpen = false" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
></div> />
<div <Input
class="absolute inset-0 flex items-start justify-center p-4 pt-20 sm:pt-28" v-model="query"
@click.self="isOpen = false" placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
> class="w-full pl-10 pr-16"
<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" <button
role="dialog" v-if="query"
aria-modal="true" @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 <div
class="p-4 border-b border-slate-200/60" class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
ref="inputWrap"
> >
<div class="relative"> <span>Naročniki</span>
<div class="relative"> <Badge variant="secondary">{{ result.clients.length }}</Badge>
<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>
</div> </div>
<div <div class="space-y-1">
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300" <Link
> v-for="client in result.clients"
<div :key="client.client_uuid"
v-if="!query" :href="route('client.show', { uuid: client.client_uuid })"
class="p-8 text-sm text-slate-500 text-center space-y-2" 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> <Badge
<p class="text-xs"> variant="outline"
Namig: uporabi class="shrink-0 w-6 h-6 flex items-center justify-center"
<kbd >C</Badge
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"
> >
Ni rezultatov. <span class="font-medium">{{ client.full_name }}</span>
</div> </Link>
</div>
</div> </div>
</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>
</div> </div>
</transition> </DialogContent>
</teleport> </Dialog>
</template> </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> <script setup>
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { usePage, Link, router } from "@inertiajs/vue3"; 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 { BellIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button"; 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 page = usePage();
const due = computed( const due = computed(
@@ -83,12 +83,8 @@ function markRead(item) {
</script> </script>
<template> <template>
<Dropdown <Popover>
align="right" <PopoverTrigger as-child>
width="72"
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
>
<template #trigger>
<Button variant="ghost" size="default" class="relative"> <Button variant="ghost" size="default" class="relative">
<BellIcon /> <BellIcon />
@@ -100,32 +96,30 @@ function markRead(item) {
{{ count }} {{ count }}
</Badge> </Badge>
</Button> </Button>
</template> </PopoverTrigger>
<template #content> <PopoverContent align="end" class="w-96 p-0">
<div <div class="px-4 py-3 flex items-center justify-between border-b">
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 class="text-sm font-medium">Zapadejo danes</span>
>
<span>Zapadejo danes</span>
<Link <Link
:href="route('notifications.unread')" :href="route('notifications.unread')"
class="text-indigo-600 hover:text-indigo-700" class="text-sm text-primary hover:underline"
>Vsa obvestila</Link >Vsa obvestila</Link
> >
</div> </div>
<!-- Scrollable content area with max height -->
<div class="max-h-80 overflow-auto"> <ScrollArea class="h-72">
<div v-if="!count" class="px-3 py-3 text-sm text-gray-500"> <div v-if="!count" class="px-4 py-8 text-center">
Ni zapadlih aktivnosti danes. <p class="text-sm text-muted-foreground">Ni zapadlih aktivnosti danes.</p>
</div> </div>
<ul v-else class="divide-y"> <div v-else class="divide-y">
<li <div
v-for="item in items" v-for="item in items"
:key="item.id" :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="flex-1 min-w-0 space-y-1">
<div class="font-medium text-gray-800 truncate"> <div class="font-medium truncate">
<template v-if="item.contract?.uuid"> <template v-if="item.contract?.uuid">
Pogodba: Pogodba:
<Link <Link
@@ -135,7 +129,7 @@ function markRead(item) {
client_case: item.contract.client_case.uuid, 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 || "—" }} {{ item.contract?.reference || "—" }}
</Link> </Link>
@@ -148,7 +142,7 @@ function markRead(item) {
:href=" :href="
route('clientCase.show', { client_case: item.client_case.uuid }) 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 || "—" }} {{ item.client_case?.person?.full_name || "—" }}
</Link> </Link>
@@ -157,37 +151,38 @@ function markRead(item) {
</div> </div>
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) --> <!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
<div <div
class="text-xs text-gray-500 truncate" class="text-xs text-muted-foreground truncate"
v-if="item.contract?.client?.person?.full_name" v-if="item.contract?.client?.person?.full_name"
> >
Partner: {{ item.contract.client.person.full_name }} Partner: {{ item.contract.client.person.full_name }}
</div> </div>
<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" v-else-if="item.client_case?.client?.person?.full_name"
> >
Partner: {{ item.client_case.client.person.full_name }} Partner: {{ item.client_case.client.person.full_name }}
</div> </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) }} {{ fmtEUR(item.contract?.account?.balance_amount) }}
</div> </div>
</div> </div>
<div class="flex flex-col items-end gap-1"> <div class="flex flex-col items-end gap-1.5 shrink-0">
<div class="text-xs text-gray-500 whitespace-nowrap"> <div class="text-xs text-muted-foreground whitespace-nowrap">
{{ fmtDate(item.due_date) }} {{ fmtDate(item.due_date) }}
</div> </div>
<button <Button
type="button" variant="ghost"
class="text-[11px] text-gray-400 hover:text-gray-600" size="sm"
class="h-6 px-2 text-xs"
@click.stop="markRead(item)" @click.stop="markRead(item)"
title="Skrij obvestilo" title="Skrij obvestilo"
> >
Skrij Skrij
</button> </Button>
</div> </div>
</li> </div>
</ul> </div>
</div> </ScrollArea>
</template> </PopoverContent>
</Dropdown> </Popover>
</template> </template>
+70 -60
View File
@@ -2,6 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue"; import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3"; import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue"; import { ref, computed, nextTick } from "vue";
import axios from "axios";
import { import {
Card, Card,
CardContent, CardContent,
@@ -39,6 +40,9 @@ import {
BadgeCheckIcon, BadgeCheckIcon,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions"; 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({ const props = defineProps({
profiles: { type: Array, default: () => [] }, profiles: { type: Array, default: () => [] },
@@ -123,13 +127,19 @@ const contracts = ref({
const segmentId = ref(null); const segmentId = ref(null);
const search = ref(""); const search = ref("");
const clientId = ref(null); const clientId = ref(null);
const startDateFrom = ref(""); const startDateRange = ref({ start: null, end: null });
const startDateTo = ref(""); const promiseDateRange = ref({ start: null, end: null });
const promiseDateFrom = ref("");
const promiseDateTo = ref("");
const onlyMobile = ref(false); const onlyMobile = ref(false);
const onlyValidated = ref(false); const onlyValidated = ref(false);
const loadingContracts = 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 selectedContractIds = ref(new Set());
const perPage = ref(25); const perPage = ref(25);
@@ -153,6 +163,11 @@ const contractColumns = [
accessorFn: (row) => row.selected_phone?.number || "—", accessorFn: (row) => row.selected_phone?.number || "—",
header: "Izbrana številka", header: "Izbrana številka",
}, },
{
id: "segment",
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
header: "Segment",
},
{ accessorKey: "no_phone_reason", header: "Opomba" }, { accessorKey: "no_phone_reason", header: "Opomba" },
]; ];
@@ -175,19 +190,22 @@ async function loadContracts(url = null) {
if (segmentId.value) params.append("segment_id", segmentId.value); if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value); if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value); if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value); if (startDateRange.value?.start)
if (startDateTo.value) params.append("start_date_to", startDateTo.value); params.append("start_date_from", startDateRange.value.start);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value); if (startDateRange.value?.end)
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value); 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 (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1"); if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value); params.append("per_page", perPage.value);
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`; const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const res = await fetch(target, { const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" }, headers: { "X-Requested-With": "XMLHttpRequest" },
}); });
const json = await res.json();
// Wait for next tick before updating to avoid Vue reconciliation issues // Wait for next tick before updating to avoid Vue reconciliation issues
await nextTick(); await nextTick();
@@ -238,10 +256,13 @@ function goToPage(page) {
if (segmentId.value) params.append("segment_id", segmentId.value); if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value); if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value); if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value); if (startDateRange.value?.start)
if (startDateTo.value) params.append("start_date_to", startDateTo.value); params.append("start_date_from", startDateRange.value.start);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value); if (startDateRange.value?.end) params.append("start_date_to", startDateRange.value.end);
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value); 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 (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1"); if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value); params.append("per_page", perPage.value);
@@ -255,10 +276,8 @@ function resetFilters() {
segmentId.value = null; segmentId.value = null;
clientId.value = null; clientId.value = null;
search.value = ""; search.value = "";
startDateFrom.value = ""; startDateRange.value = { start: null, end: null };
startDateTo.value = ""; promiseDateRange.value = { start: null, end: null };
promiseDateFrom.value = "";
promiseDateTo.value = "";
onlyMobile.value = false; onlyMobile.value = false;
onlyValidated.value = false; onlyValidated.value = false;
contracts.value = { contracts.value = {
@@ -448,9 +467,10 @@ const numbersCount = computed(() => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
:checked="form.delivery_report" :model-value="form.delivery_report"
@update:checked="(val) => (form.delivery_report = val)" @update:model-value="(val) => (form.delivery_report = val)"
id="delivery-report" id="delivery-report"
:disabled="true"
/> />
<Label for="delivery-report" class="cursor-pointer text-sm"> <Label for="delivery-report" class="cursor-pointer text-sm">
Zahtevaj delivery report Zahtevaj delivery report
@@ -553,17 +573,15 @@ const numbersCount = computed(() => {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Stranka</Label> <Label>Stranka</Label>
<Select v-model="clientId" @update:model-value="loadContracts()"> <AppCombobox
<SelectTrigger> v-model="clientId"
<SelectValue placeholder="Vse stranke" /> :items="clientItems"
</SelectTrigger> placeholder="Vse stranke"
<SelectContent> search-placeholder="Išči stranko..."
<SelectItem :value="null">Vse stranke</SelectItem> empty-text="Stranka ni najdena."
<SelectItem v-for="c in clients" :key="c.id" :value="c.id"> button-class="w-full"
{{ c.name }} @update:model-value="loadContracts()"
</SelectItem> />
</SelectContent>
</Select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Iskanje po referenci</Label> <Label>Iskanje po referenci</Label>
@@ -586,29 +604,21 @@ const numbersCount = computed(() => {
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3"> <div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p> <p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
<div class="grid grid-cols-2 gap-2"> <AppRangeDatePicker
<div class="space-y-2"> v-model="startDateRange"
<Label class="text-xs">Od</Label> placeholder="Izberi obdobje"
<Input v-model="startDateFrom" type="date" /> button-class="w-full"
</div> :number-of-months="1"
<div class="space-y-2"> />
<Label class="text-xs">Do</Label>
<Input v-model="startDateTo" type="date" />
</div>
</div>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum obljube plačila</p> <p class="text-sm text-muted-foreground">Datum obljube plačila</p>
<div class="grid grid-cols-2 gap-2"> <AppRangeDatePicker
<div class="space-y-2"> v-model="promiseDateRange"
<Label class="text-xs">Od</Label> placeholder="Izberi obdobje"
<Input v-model="promiseDateFrom" type="date" /> button-class="w-full"
</div> :number-of-months="1"
<div class="space-y-2"> />
<Label class="text-xs">Do</Label>
<Input v-model="promiseDateTo" type="date" />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -621,8 +631,8 @@ const numbersCount = computed(() => {
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
:checked="onlyMobile" :model-value="onlyMobile"
@update:checked=" @update:model-value="
(val) => { (val) => {
onlyMobile = val; onlyMobile = val;
} }
@@ -635,8 +645,8 @@ const numbersCount = computed(() => {
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
:checked="onlyValidated" :model-value="onlyValidated"
@update:checked=" @update:model-value="
(val) => { (val) => {
onlyValidated = val; onlyValidated = val;
} }
@@ -653,11 +663,11 @@ const numbersCount = computed(() => {
<!-- Action buttons --> <!-- Action buttons -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button @click="loadContracts()"> <Button @click="loadContracts()">
<SearchIcon class="h-4 w-4 mr-2" /> <SearchIcon class="h-4 w-4" />
Išči pogodbe Išči pogodbe
</Button> </Button>
<Button @click="resetFilters" variant="outline"> <Button @click="resetFilters" variant="outline">
<XCircleIcon class="h-4 w-4 mr-2" /> <XCircleIcon class="h-4 w-4" />
Počisti filtre Počisti filtre
</Button> </Button>
</div> </div>
@@ -669,7 +679,7 @@ const numbersCount = computed(() => {
<CardHeader> <CardHeader>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<CardTitle>Rezultati iskanja</CardTitle> <CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
<CardDescription v-if="contracts.meta.total > 0"> <CardDescription v-if="contracts.meta.total > 0">
Najdeno {{ contracts.meta.total }} Najdeno {{ contracts.meta.total }}
{{ {{
@@ -689,7 +699,7 @@ const numbersCount = computed(() => {
variant="secondary" variant="secondary"
class="text-sm" class="text-sm"
> >
<CheckCircle2Icon class="h-3 w-3 mr-1" /> <CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }} Izbrano: {{ selectedContractIds.size }}
</Badge> </Badge>
<Button <Button
@@ -702,7 +712,7 @@ const numbersCount = computed(() => {
@click="submitCreateFromContracts" @click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts" :disabled="selectedContractIds.size === 0 || creatingFromContracts"
> >
<SaveIcon class="h-4 w-4 mr-2" /> <SaveIcon class="h-4 w-4" />
Ustvari paket ({{ selectedContractIds.size }} Ustvari paket ({{ selectedContractIds.size }}
{{ {{
selectedContractIds.size === 1 selectedContractIds.size === 1
+9 -9
View File
@@ -18,6 +18,7 @@ import {
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue"; import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { PackageIcon, PlusIcon, 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 AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({ const props = defineProps({
packages: { type: Object, required: true }, packages: { type: Object, required: true },
@@ -29,7 +30,6 @@ const showDeleteDialog = ref(false);
const columns = [ const columns = [
{ accessorKey: "id", header: "ID" }, { accessorKey: "id", header: "ID" },
{ accessorKey: "uuid", header: "UUID" },
{ accessorKey: "name", header: "Ime" }, { accessorKey: "name", header: "Ime" },
{ accessorKey: "type", header: "Tip" }, { accessorKey: "type", header: "Tip" },
{ accessorKey: "status", header: "Status" }, { accessorKey: "status", header: "Status" },
@@ -84,7 +84,7 @@ function confirmDelete() {
</div> </div>
<Link :href="route('admin.packages.create')"> <Link :href="route('admin.packages.create')">
<Button> <Button>
<PlusIcon class="h-4 w-4 mr-2" /> <PlusIcon class="h-4 w-4" />
Nov paket Nov paket
</Button> </Button>
</Link> </Link>
@@ -111,10 +111,6 @@ function confirmDelete() {
:meta="packages" :meta="packages"
route-name="admin.packages.index" 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 }"> <template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span> <span class="text-sm">{{ row.name ?? "—" }}</span>
</template> </template>
@@ -128,7 +124,9 @@ function confirmDelete() {
</template> </template>
<template #cell-finished_at="{ row }"> <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>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
@@ -157,8 +155,10 @@ function confirmDelete() {
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle> <AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete">#{{ packageToDelete.id }} - {{ packageToDelete.name || 'Brez imena' }}</strong>? <strong v-if="packageToDelete"
Tega dejanja ni mogoče razveljaviti. >#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -54,7 +54,9 @@ const form = useInertiaForm({
props.actions[0].decisions.length > 0 props.actions[0].decisions.length > 0
? props.actions[0].decisions[0].id ? props.actions[0].decisions[0].id
: null, : null,
contract_uuids: props.contractUuid ? [props.contractUuid] : [], contract_uuids: props.contractUuid ? [props.contractUuid] : (props.contracts && Array.isArray(props.contracts)
? props.contracts.map((c) => c.uuid)
: []),
send_auto_mail: true, send_auto_mail: true,
attach_documents: false, attach_documents: false,
attachment_document_ids: [], attachment_document_ids: [],
@@ -104,7 +106,9 @@ watch(
() => props.show, () => props.show,
(visible) => { (visible) => {
if (visible) { if (visible) {
form.contract_uuids = props.contractUuid ? [props.contractUuid] : []; form.contract_uuids = props.contractUuid ? [props.contractUuid] : (props.contracts && Array.isArray(props.contracts)
? props.contracts.map((c) => c.uuid)
: []);
} }
} }
); );
@@ -120,10 +124,11 @@ const store = async () => {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
}; };
const contractUuids = Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0 const contractUuids =
? form.contract_uuids Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
: null; ? form.contract_uuids
: null;
const isMultipleContracts = contractUuids && contractUuids.length > 1; const isMultipleContracts = contractUuids && contractUuids.length > 1;
form form
@@ -175,37 +180,43 @@ const autoMailRequiresContract = computed(() => {
}); });
const contractItems = computed(() => { const contractItems = computed(() => {
return pageContracts.value.map(c => ({ return pageContracts.value.map((c) => ({
value: c.uuid, value: c.uuid,
label: `${c.reference}${c.name ? ` - ${c.name}` : ''}` label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`,
})); }));
}); });
const autoMailDisabled = computed(() => { const autoMailDisabled = computed(() => {
if (!showSendAutoMail()) return false; if (!showSendAutoMail()) return false;
// Disable if multiple contracts selected // Disable if multiple contracts selected
if (form.contract_uuids && form.contract_uuids.length > 1) return true; if (form.contract_uuids && form.contract_uuids.length > 1) return true;
// Disable if template requires contract but none selected // Disable if template requires contract but none selected
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) { if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return true; return true;
} }
return false; return false;
}); });
const autoMailDisabledHint = computed(() => { const autoMailDisabledHint = computed(() => {
if (!showSendAutoMail()) return ""; if (!showSendAutoMail()) return "";
if (form.contract_uuids && form.contract_uuids.length > 1) { if (form.contract_uuids && form.contract_uuids.length > 1) {
return "Avtomatska e-pošta ni na voljo pri več pogodbah."; return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
} }
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) { if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."; return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
} }
return ""; return "";
}); });
watch( watch(
@@ -333,133 +344,148 @@ watch(
@confirm="store" @confirm="store"
> >
<form @submit.prevent="store"> <form @submit.prevent="store">
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>Akcija</Label> <Label>Akcija</Label>
<Select v-model="form.action_id" :disabled="!actions || !actions.length"> <Select v-model="form.action_id" :disabled="!actions || !actions.length">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi akcijo" /> <SelectValue placeholder="Izberi akcijo" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id"> <SelectItem v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }} {{ a.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Odločitev</Label> <Label>Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!decisions || !decisions.length"> <Select v-model="form.decision_id" :disabled="!decisions || !decisions.length">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi odločitev" /> <SelectValue placeholder="Izberi odločitev" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem v-for="d in decisions" :key="d.id" :value="d.id"> <SelectItem v-for="d in decisions" :key="d.id" :value="d.id">
{{ d.name }} {{ d.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Pogodbe</Label> <Label>Pogodbe</Label>
<AppMultiSelect <AppMultiSelect
v-model="form.contract_uuids" v-model="form.contract_uuids"
:items="contractItems" :items="contractItems"
placeholder="Izberi pogodbe (neobvezno)" placeholder="Izberi pogodbe (neobvezno)"
search-placeholder="Išči pogodbo..." search-placeholder="Išči pogodbo..."
empty-text="Ni pogodb." empty-text="Ni pogodb."
:clearable="true" :clearable="true"
:show-selected-chips="true" :show-selected-chips="true"
/> />
<p v-if="form.contract_uuids && form.contract_uuids.length > 1" class="text-xs text-muted-foreground"> <p
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako pogodbo). v-if="form.contract_uuids && form.contract_uuids.length > 1"
</p> class="text-xs text-muted-foreground"
</div> >
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako
pogodbo).
</p>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityNote">Opomba</Label> <Label for="activityNote">Opomba</Label>
<Textarea <Textarea
id="activityNote" id="activityNote"
v-model="form.note" v-model="form.note"
class="block w-full" class="block w-full max-h-72"
placeholder="Opomba" placeholder="Opomba"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityDueDate">Datum zapadlosti</Label> <Label for="activityDueDate">Datum zapadlosti</Label>
<DatePicker <DatePicker
id="activityDueDate" id="activityDueDate"
v-model="form.due_date" v-model="form.due_date"
format="dd.MM.yyyy" format="dd.MM.yyyy"
:error="form.errors.due_date" :error="form.errors.due_date"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityAmount">Znesek</Label> <Label for="activityAmount">Znesek</Label>
<CurrencyInput <CurrencyInput
id="activityAmount" id="activityAmount"
v-model="form.amount" v-model="form.amount"
:precision="{ min: 0, max: 4 }" :precision="{ min: 0, max: 4 }"
placeholder="0,00" placeholder="0,00"
class="w-full" class="w-full"
/> />
</div> </div>
<div v-if="showSendAutoMail()" class="space-y-2"> <div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Switch <Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
v-model="form.send_auto_mail" <Label class="cursor-pointer">Send auto email</Label>
:disabled="autoMailDisabled"
/>
<Label class="cursor-pointer">Send auto email</Label>
</div>
</div> </div>
<p v-if="autoMailDisabled" class="text-xs text-amber-600"> </div>
{{ autoMailDisabledHint }} <p v-if="autoMailDisabled" class="text-xs text-amber-600">
</p> {{ autoMailDisabledHint }}
</p>
<div v-if="templateAllowsAttachments && form.contract_uuids && form.contract_uuids.length === 1" class="mt-3"> <div
<label class="inline-flex items-center gap-2"> v-if="
<Switch v-model="form.attach_documents" /> templateAllowsAttachments &&
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span> form.contract_uuids &&
</label> form.contract_uuids.length === 1
<div "
v-if="form.attach_documents" class="mt-3"
class="mt-2 border rounded p-2 max-h-48 overflow-auto" >
> <label class="inline-flex items-center gap-2">
<div class="text-xs text-gray-600 mb-2"> <Switch v-model="form.attach_documents" />
Izberite dokumente, ki bodo poslani kot priponke: <span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
</div> </label>
<div class="space-y-1"> <div
<template v-for="c in pageContracts" :key="c.uuid || c.id"> v-if="form.attach_documents"
<div v-if="c.uuid === form.contract_uuids[0]"> class="mt-2 border rounded p-2 max-h-48 overflow-auto"
<div class="font-medium text-sm text-gray-700 mb-1"> >
Pogodba {{ c.reference }} <div class="text-xs text-gray-600 mb-2">
</div> Izberite dokumente, ki bodo poslani kot priponke:
<div class="space-y-1"> </div>
<div <div class="space-y-1">
v-for="doc in availableContractDocs" <template v-for="c in pageContracts" :key="c.uuid || c.id">
:key="doc.uuid || doc.id" <div v-if="c.uuid === form.contract_uuids[0]">
class="flex items-center gap-2 text-sm" <div class="font-medium text-sm text-gray-700 mb-1">
> Pogodba {{ c.reference }}
<Switch </div>
:model-value="form.attachment_document_ids.includes(doc.id)" <div class="space-y-1">
@update:model-value="(checked) => { <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 (checked) {
if (!form.attachment_document_ids.includes(doc.id)) { if (!form.attachment_document_ids.includes(doc.id)) {
form.attachment_document_ids.push(doc.id); form.attachment_document_ids.push(doc.id);
} }
} else { } 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" <span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }}, >({{ doc.extension?.toUpperCase() || "" }},
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span {{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
@@ -467,22 +493,23 @@ watch(
</div> </div>
</div> </div>
</div> </div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div> </div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</ActionMessage>
</div> </div>
</form>
</CreateDialog> <ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</ActionMessage>
</div>
</form>
</CreateDialog>
</template> </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 CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue"; import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue"; import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue"; import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -33,6 +34,16 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue"; import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button"; 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({ const props = defineProps({
client: { type: Object, default: null }, client: { type: Object, default: null },
@@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
selectedContract.value = null; 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 // Columns configuration
const columns = computed(() => [ const columns = computed(() => [
{ key: "reference", label: "Ref.", sortable: false, align: "center" }, { key: "reference", label: "Ref.", sortable: false, align: "center" },
@@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
<div class="text-gray-500">Ni meta podatkov.</div> <div class="text-gray-500">Ni meta podatkov.</div>
</template> </template>
</div> </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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
:edit="edit" :edit="edit"
/> />
<ContractMetaEditDialog
:show="showMetaEditDialog"
:client_case="client_case"
:contract="selectedContract"
@close="closeMetaEditDialog"
/>
<!-- Generate Document Dialog --> <!-- Generate Document Dialog -->
<CreateDialog <CreateDialog
:show="showGenerateDialog" :show="showGenerateDialog"
@@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
@confirm="submitGenerate" @confirm="submitGenerate"
> >
<div class="space-y-4"> <div class="space-y-4">
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Predloga</label> <Label>Predloga</Label>
<select <Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
v-model="selectedTemplateSlug" <SelectTrigger>
@change="onTemplateChange" <SelectValue placeholder="Izberi predlogo..." />
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" </SelectTrigger>
> <SelectContent>
<option :value="null">Izberi predlogo...</option> <SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
<option v-for="t in templates" :key="t.slug" :value="t.slug"> {{ t.name }} (v{{ t.version }})
{{ t.name }} (v{{ t.version }}) </SelectItem>
</option> </SelectContent>
</select> </Select>
</div> </div>
<!-- Custom inputs --> <!-- Custom inputs -->
@@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
<div class="border-t border-gray-200 pt-4"> <div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3> <h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="token in customTokenList" :key="token"> <div v-for="token in customTokenList" :key="token" class="space-y-2">
<label class="block text-sm font-medium text-gray-700"> <Label>
{{ token.replace(/^custom\./, "") }} {{ token.replace(/^custom\./, "") }}
</label> </Label>
<input <Textarea
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
v-model="customInputs[token.replace(/^custom\./, '')]" v-model="customInputs[token.replace(/^custom\./, '')]"
type="text" rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" />
<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>
</div> </div>
@@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
<!-- Address overrides --> <!-- Address overrides -->
<div class="border-t border-gray-200 pt-4 space-y-3"> <div class="border-t border-gray-200 pt-4 space-y-3">
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3> <h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label> <Label>Naslov stranke</Label>
<select <Select v-model="clientAddressSource">
v-model="clientAddressSource" <SelectTrigger>
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" <SelectValue />
> </SelectTrigger>
<option value="client">Stranka</option> <SelectContent>
<option value="case_person">Oseba primera</option> <SelectItem value="client">Stranka</SelectItem>
</select> <SelectItem value="case_person">Oseba primera</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label> <Label>Naslov osebe</Label>
<select <Select v-model="personAddressSource">
v-model="personAddressSource" <SelectTrigger>
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" <SelectValue />
> </SelectTrigger>
<option value="case_person">Oseba primera</option> <SelectContent>
<option value="client">Stranka</option> <SelectItem value="case_person">Oseba primera</SelectItem>
</select> <SelectItem value="client">Stranka</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
+12 -10
View File
@@ -107,7 +107,7 @@ const onDocSaved = () => {
router.reload({ only: ["documents"] }); router.reload({ only: ["documents"] });
}; };
const viewer = ref({ open: false, src: "", title: "" }); const viewer = ref({ open: false, src: "", title: "", mimeType: "", filename: "" });
const openViewer = (doc) => { const openViewer = (doc) => {
const kind = classifyDocument(doc); const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract"); const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -122,7 +122,13 @@ const openViewer = (doc) => {
client_case: props.client_case.uuid, client_case: props.client_case.uuid,
document: doc.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 { } else {
const url = const url =
isContractDoc && doc.contract_uuid isContractDoc && doc.contract_uuid
@@ -140,6 +146,8 @@ const openViewer = (doc) => {
const closeViewer = () => { const closeViewer = () => {
viewer.value.open = false; viewer.value.open = false;
viewer.value.src = ""; viewer.value.src = "";
viewer.value.mimeType = "";
viewer.value.filename = "";
}; };
const clientDetails = ref(false); const clientDetails = ref(false);
@@ -210,14 +218,6 @@ const closeDrawer = () => {
drawerAddActivity.value = false; drawerAddActivity.value = false;
}; };
const showClientDetails = () => {
clientDetails.value = false;
};
const hideClietnDetails = () => {
clientDetails.value = true;
};
// Attach segment to case // Attach segment to case
const showAttachSegment = ref(false); const showAttachSegment = ref(false);
const openAttachSegment = () => { const openAttachSegment = () => {
@@ -490,6 +490,8 @@ const submitAttachSegment = () => {
:show="viewer.open" :show="viewer.open"
:src="viewer.src" :src="viewer.src"
:title="viewer.title" :title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer" @close="closeViewer"
/> />
</AppLayout> </AppLayout>
+91 -5
View File
@@ -24,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ButtonGroup } from "@/Components/ui/button-group"; import { ButtonGroup } from "@/Components/ui/button-group";
import AppPopover from "@/Components/app/ui/AppPopover.vue"; import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { Filter, LinkIcon, FileDown } from "lucide-vue-next"; import { Filter, LinkIcon, FileDown, LayoutIcon } from "lucide-vue-next";
import { Card } from "@/Components/ui/card"; import { Card } from "@/Components/ui/card";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { hasPermission } from "@/Services/permissions"; import { hasPermission } from "@/Services/permissions";
import InputLabel from "@/Components/InputLabel.vue"; import InputLabel from "@/Components/InputLabel.vue";
import { cn } from "@/lib/utils"; 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({ const props = defineProps({
client: Object, client: Object,
contracts: Object, contracts: Object,
filters: Object, filters: Object,
segments: Object, segments: Array,
types: Object, types: Object,
}); });
@@ -59,10 +72,20 @@ const selectedSegments = ref(
: [] : []
); );
const filterPopoverOpen = ref(false); const filterPopoverOpen = ref(false);
const selectedContracts = ref([]);
const changeSegmentDialogOpen = ref(false);
const contractTable = ref(null);
const exportDialogOpen = ref(false); const exportDialogOpen = ref(false);
const exportScope = ref("current"); const exportScope = ref("current");
const exportColumns = ref(["reference", "customer", "address", "start", "segment", "balance"]); const exportColumns = ref([
"reference",
"customer",
"address",
"start",
"segment",
"balance",
]);
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
@@ -85,6 +108,12 @@ const allColumnsSelected = computed(
const exportDisabled = computed( const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value () => exportColumns.value.length === 0 || isExporting.value
); );
const segmentSelectItems = computed(() =>
props.segments.map((val, i) => ({
label: val.name,
value: val.id,
}))
);
function applyDateFilter() { function applyDateFilter() {
filterPopoverOpen.value = false; filterPopoverOpen.value = false;
@@ -288,6 +317,24 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null; 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> </script>
<template> <template>
@@ -357,6 +404,7 @@ function extractFilenameFromHeaders(headers) {
</Link> </Link>
</div> </div>
<DataTable <DataTable
ref="contractTable"
:columns="[ :columns="[
{ key: 'reference', label: 'Referenca', sortable: false }, { key: 'reference', label: 'Referenca', sortable: false },
{ key: 'customer', label: 'Stranka', sortable: false }, { key: 'customer', label: 'Stranka', sortable: false },
@@ -380,11 +428,13 @@ function extractFilenameFromHeaders(headers) {
row-key="uuid" row-key="uuid"
:only-props="['contracts']" :only-props="['contracts']"
:page-size-options="[10, 15, 25, 50, 100]" :page-size-options="[10, 15, 25, 50, 100]"
:enable-row-selection="true"
@selection:change="handleSelectionChange"
page-param-name="contracts_page" page-param-name="contracts_page"
per-page-param-name="contracts_per_page" per-page-param-name="contracts_per_page"
:show-toolbar="true" :show-toolbar="true"
> >
<template #toolbar-filters> <template #toolbar-filters="{ table }">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppPopover <AppPopover
v-model:open="filterPopoverOpen" v-model:open="filterPopoverOpen"
@@ -481,6 +531,32 @@ function extractFilenameFromHeaders(headers) {
<FileDown class="h-4 w-4" /> <FileDown class="h-4 w-4" />
Izvozi v Excel Izvozi v Excel
</Button> </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> </div>
</template> </template>
<template #cell-reference="{ row }"> <template #cell-reference="{ row }">
@@ -519,7 +595,7 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</div> </div>
</div> </div>
<!-- Excel export dialog -->
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog"> <DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title> <template #title>
<div class="space-y-1"> <div class="space-y-1">
@@ -626,5 +702,15 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</template> </template>
</DialogModal> </DialogModal>
<!-- Change segment selected contracts dialog -->
<FormChangeSegment
:show="changeSegmentDialogOpen"
@close="changeSegmentDialogOpen = false"
:segments="segmentSelectItems"
:contracts="selectedContracts"
:clear-selected-rows="clearContractTableSelected"
/>
</AppLayout> </AppLayout>
</template> </template>
+4 -6
View File
@@ -6,10 +6,8 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { hasPermission } from "@/Services/permissions"; import { hasPermission } from "@/Services/permissions";
import { Button } from "@/Components/ui/button"; 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 { Input } from "@/Components/ui/input";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,8 +25,7 @@ import {
import { useForm } from "vee-validate"; import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod"; import * as z from "zod";
import ActionMessage from "@/Components/ActionMessage.vue"; import { Plus, UsersRoundIcon } from "lucide-vue-next";
import { Mail, Plug2Icon, Plus, UsersRoundIcon } from "lucide-vue-next";
import { Separator } from "@/Components/ui/separator"; import { Separator } from "@/Components/ui/separator";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
@@ -162,7 +159,7 @@ const fmtCurrency = (v) => {
</script> </script>
<template> <template>
<AppLayout> <AppLayout title="Clients">
<template #header> </template> <template #header> </template>
<div class="py-6"> <div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@@ -201,6 +198,7 @@ const fmtCurrency = (v) => {
:show-pagination="false" :show-pagination="false"
:show-toolbar="true" :show-toolbar="true"
:hoverable="true" :hoverable="true"
:page-size="100"
row-key="uuid" row-key="uuid"
:striped="true" :striped="true"
empty-text="Ni najdenih naročnikov." 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 InputLabel from "@/Components/InputLabel.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue"; import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { toNumber } from "lodash";
const props = defineProps({ const props = defineProps({
setting: Object, setting: Object,
unassignedContracts: Object, unassignedContracts: Object,
assignedContracts: Object, assignedContracts: Object,
users: Array, users: Array,
unassignedClients: Array, unassignedClients: [Array, Object],
assignedClients: Array, assignedClients: [Array, Object],
filters: Object, filters: Object,
}); });
@@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
: [] : []
); );
const unassignedContractTable = ref(null);
const form = useForm({ const form = useForm({
contract_uuid: null, contract_uuid: null,
assigned_user_id: null, assigned_user_id: null,
@@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
console.log(selectedContractUuids.value); 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) // Format helpers (Slovenian formatting)
// Initialize search and filter from URL params // Initialize search and filter from URL params
@@ -296,6 +307,7 @@ function assignSelected() {
bulkForm.contract_uuids = selectedContractUuids.value; bulkForm.contract_uuids = selectedContractUuids.value;
bulkForm.post(route("fieldjobs.assign-bulk"), { bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => { onSuccess: () => {
unassignedContractTable.value.clearSelection();
selectedContractUuids.value = []; selectedContractUuids.value = [];
bulkForm.contract_uuids = []; bulkForm.contract_uuids = [];
}, },
@@ -304,7 +316,11 @@ function assignSelected() {
function cancelAssignment(contract) { function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid }; 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 // Column definitions for DataTableNew2
@@ -437,6 +453,7 @@ const assignedRows = computed(() =>
</div> </div>
</div> </div>
<DataTable <DataTable
ref="unassignedContractTable"
:columns="unassignedColumns" :columns="unassignedColumns"
:data="unassignedRows" :data="unassignedRows"
:meta="{ :meta="{
@@ -449,6 +466,8 @@ const assignedRows = computed(() =>
links: unassignedContracts.links, links: unassignedContracts.links,
}" }"
row-key="uuid" row-key="uuid"
:enable-row-selection="true"
@selection:change="handleContractSelection"
:page-size="props.unassignedContracts?.per_page || 10" :page-size="props.unassignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]" :page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true" :show-toolbar="true"
@@ -482,7 +501,10 @@ const assignedRows = computed(() =>
<AppMultiSelect <AppMultiSelect
v-model="filterUnassignedSelectedClient" v-model="filterUnassignedSelectedClient"
:items=" :items="
(props.unassignedClients || []).map((client) => ({ (Array.isArray(props.unassignedClients)
? props.unassignedClients
: props.unassignedClients?.data || []
).map((client) => ({
value: client.uuid, value: client.uuid,
label: client.person.full_name, label: client.person.full_name,
})) }))
@@ -497,14 +519,6 @@ const assignedRows = computed(() =>
</AppPopover> </AppPopover>
</div> </div>
</template> </template>
<template #cell-_select="{ row }">
<Checkbox
@update:model-value="
(checked) => toggleContractSelection(row.uuid, checked)
"
/>
</template>
<template #cell-case_person="{ row }"> <template #cell-case_person="{ row }">
<Link <Link
v-if="row.client_case?.uuid" v-if="row.client_case?.uuid"
@@ -605,7 +619,10 @@ const assignedRows = computed(() =>
<AppMultiSelect <AppMultiSelect
v-model="filterAssignedSelectedClient" v-model="filterAssignedSelectedClient"
:items=" :items="
(props.assignedClients || []).map((client) => ({ (Array.isArray(props.assignedClients)
? props.assignedClients
: props.assignedClients?.data || []
).map((client) => ({
value: client.uuid, value: client.uuid,
label: client.person.full_name, label: client.person.full_name,
})) }))
+1 -1
View File
@@ -245,7 +245,7 @@ async function startImport() {
<!-- Has Header Checkbox --> <!-- Has Header Checkbox -->
<div class="flex items-center space-x-2"> <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 <Label
for="has-header" for="has-header"
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 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 // Simulation (generic or payments) state
const showPaymentSim = ref(false); const showPaymentSim = ref(false);
const paymentSimLoading = ref(false); const paymentSimLoading = ref(false);
@@ -1307,7 +1317,8 @@ async function fetchSimulation() {
<Checkbox <Checkbox
:id="'show-missing-checkbox'" :id="'show-missing-checkbox'"
:checked="showMissingEnabled" :checked="showMissingEnabled"
@update:checked=" :model-value="showMissingEnabled"
@update:model-value="
(val) => { (val) => {
showMissingEnabled = val; showMissingEnabled = val;
saveImportOptions(); saveImportOptions();
@@ -1339,6 +1350,7 @@ async function fetchSimulation() {
:can-process="canProcess" :can-process="canProcess"
:selected-mappings-count="selectedMappingsCount" :selected-mappings-count="selectedMappingsCount"
@preview="openPreview" @preview="openPreview"
@download="downloadImport"
@save-mappings="saveMappings" @save-mappings="saveMappings"
@process-import="processImport" @process-import="processImport"
@simulate="openSimulation" @simulate="openSimulation"
@@ -4,9 +4,10 @@ import {
ArrowPathIcon, ArrowPathIcon,
BeakerIcon, BeakerIcon,
ArrowDownOnSquareIcon, ArrowDownOnSquareIcon,
ArrowDownTrayIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import { Button } from '@/Components/ui/button'; import { Button } from "@/Components/ui/button";
import { Badge } from '@/Components/ui/badge'; import { Badge } from "@/Components/ui/badge";
const props = defineProps({ const props = defineProps({
importId: [Number, String], importId: [Number, String],
@@ -16,54 +17,68 @@ const props = defineProps({
canProcess: Boolean, canProcess: Boolean,
selectedMappingsCount: Number, selectedMappingsCount: Number,
}); });
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]); const emits = defineEmits([
"preview",
"save-mappings",
"process-import",
"simulate",
"download",
]);
</script> </script>
<template> <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 <Button
variant="secondary" variant="secondary"
@click.prevent="$emit('preview')" @click.prevent="$emit('download')"
:disabled="!importId" :disabled="!importId"
title="Preznesi originalno uvozno datoteko"
> >
<EyeIcon class="h-4 w-4 mr-2" /> <ArrowDownTrayIcon class="h-4 w-4" />
Predogled vrstic Prenos datoteko
</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> </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> </div>
</template> </template>
@@ -2,9 +2,12 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select"; 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 { Badge } from "@/Components/ui/badge";
import { Label } from "@/Components/ui/label"; 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({ const props = defineProps({
show: Boolean, show: Boolean,
limit: Number, limit: Number,
@@ -14,72 +17,187 @@ const props = defineProps({
truncated: Boolean, truncated: Boolean,
hasHeader: Boolean, hasHeader: Boolean,
}) })
const emits = defineEmits(['close','change-limit','refresh']) 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> </script>
<template> <template>
<Dialog :open="show" @update:open="(val) => !val && $emit('close')"> <Dialog :open="show" @update:open="(val) => !val && $emit('close')">
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader> <!-- Header -->
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle> <div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
</DialogHeader> <div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3 pb-3 border-b"> <h2 class="text-xl font-semibold text-gray-900">CSV Preview</h2>
<div class="flex items-center gap-2"> <p class="text-sm text-gray-500 mt-1">
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label> Showing {{ visibleRows.length }} of {{ rows.length }} rows
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }"> </p>
<SelectTrigger id="limit-select" class="w-24 h-8"> </div>
<SelectValue /> <div class="flex items-center gap-3">
</SelectTrigger> <div class="flex items-center gap-2">
<SelectContent> <Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
<SelectItem value="50">50</SelectItem> <Select :model-value="String(limit)" @update:model-value="onLimit">
<SelectItem value="100">100</SelectItem> <SelectTrigger id="limit-select" class="w-24 h-8">
<SelectItem value="200">200</SelectItem> <SelectValue />
<SelectItem value="300">300</SelectItem> </SelectTrigger>
<SelectItem value="500">500</SelectItem> <SelectContent>
</SelectContent> <SelectItem value="50">50</SelectItem>
</Select> <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> </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>
<div class="flex-1 overflow-auto border rounded-lg"> <!-- Split View -->
<Table> <div class="flex-1 flex overflow-hidden">
<TableHeader class="sticky top-0 bg-white z-10"> <!-- Left Panel - Row List -->
<TableRow> <div class="w-96 border-r bg-gray-50 overflow-y-auto">
<TableHead class="w-16">#</TableHead> <div v-if="loading" class="p-8 text-center text-gray-500">
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
</TableRow> Loading...
</TableHeader> </div>
<TableBody> <div v-else-if="!visibleRows.length" class="p-8 text-center text-gray-500">
<TableRow v-if="loading"> No rows to display
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500"> </div>
Loading <div v-else class="divide-y">
</TableCell> <button
</TableRow> v-for="row in visibleRows"
<TableRow v-for="(r, idx) in rows" :key="idx"> :key="row.index"
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell> @click="selectRow(row)"
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap"> class="w-full px-4 py-3 text-left hover:bg-white transition-colors"
{{ r[col] }} :class="{
</TableCell> 'bg-white shadow-sm': selectedRow?.index === row.index,
</TableRow> }"
<TableRow v-if="!loading && !rows.length"> >
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500"> <div class="flex items-center justify-between gap-3">
No rows <div class="flex items-center gap-3 flex-1 min-w-0">
</TableCell> <!-- Row Number -->
</TableRow> <div class="flex-shrink-0">
</TableBody> <div class="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-semibold">
</Table> {{ 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>
<div class="text-xs text-gray-500 pt-3 border-t"> <!-- Footer -->
Showing up to {{ limit }} rows from source file. <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> Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
Click a row to view full details
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,10 +1,24 @@
<script setup> <script setup>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table'; import {
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'; Table,
import { Checkbox } from '@/Components/ui/checkbox'; TableBody,
import { Input } from '@/Components/ui/input'; TableCell,
import { Badge } from '@/Components/ui/badge'; TableHead,
import { ScrollArea } from '@/Components/ui/scroll-area'; 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({ const props = defineProps({
rows: Array, rows: Array,
@@ -19,12 +33,12 @@ const props = defineProps({
mappingError: String, mappingError: String,
show: { type: Boolean, default: true }, show: { type: Boolean, default: true },
fieldsForEntity: Function, fieldsForEntity: Function,
}) });
const emits = defineEmits(['update:rows','save']) const emits = defineEmits(["update:rows", "save"]);
function duplicateTarget(row){ function duplicateTarget(row) {
if(!row || !row.entity || !row.field) return false if (!row || !row.entity || !row.field) return false;
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false return props.duplicateTargets?.has?.(row.entity + "." + row.field) || false;
} }
</script> </script>
<template> <template>
@@ -32,137 +46,192 @@ function duplicateTarget(row){
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="font-semibold"> <h3 class="font-semibold">
Detected Columns 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> </h3>
<div class="text-xs text-muted-foreground"> <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>
</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"> <div class="relative border rounded-lg">
<ScrollArea class="h-[420px]"> <ScrollArea class="h-[420px]">
<Table> <Table>
<TableHeader class="sticky top-0 z-10 bg-background"> <TableHeader class="sticky top-0 z-10 bg-background">
<TableRow class="hover:bg-transparent"> <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">Entity</TableHead>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</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-[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">Meta type</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</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-[130px] bg-muted/95 backdrop-blur"
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead> >Apply mode</TableHead
>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur"
>Skip</TableHead
>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''"> <TableRow
<TableCell class="font-medium">{{ row.source_column }}</TableCell> v-for="(row, idx) in rows"
<TableCell> :key="idx"
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted"> :class="duplicateTarget(row) ? 'bg-destructive/10' : ''"
<SelectTrigger class="h-8 text-xs"> >
<SelectValue placeholder="Select entity..." /> <TableCell class="font-medium">{{ row.source_column }}</TableCell>
</SelectTrigger> <TableCell>
<SelectContent> <Select
<SelectGroup> :model-value="row.entity || ''"
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem> @update:model-value="(val) => (row.entity = val || '')"
</SelectGroup> :disabled="isCompleted"
</SelectContent> >
</Select> <SelectTrigger class="h-8 text-xs">
</TableCell> <SelectValue placeholder="Select entity..." />
<TableCell> </SelectTrigger>
<Select <SelectContent>
:model-value="row.field || ''" <SelectGroup>
@update:model-value="(val) => row.field = val || ''" <SelectItem
:disabled="isCompleted" v-for="opt in entityOptions"
:class="duplicateTarget(row) ? 'border-destructive' : ''" :key="opt.value"
> :value="opt.value"
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''"> >{{ opt.label }}</SelectItem
<SelectValue placeholder="Select field..." /> >
</SelectTrigger> </SelectGroup>
<SelectContent> </SelectContent>
<SelectGroup> </Select>
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem> </TableCell>
</SelectGroup> <TableCell>
</SelectContent> <Select
</Select> :model-value="row.field || ''"
</TableCell> @update:model-value="(val) => (row.field = val || '')"
<TableCell> :disabled="isCompleted"
<Input :class="duplicateTarget(row) ? 'border-destructive' : ''"
v-if="row.field === 'meta'" >
v-model="(row.options ||= {}).key" <SelectTrigger
type="text" class="h-8 text-xs"
class="h-8 text-xs" :class="
placeholder="e.g. monthly_rent" duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''
:disabled="isCompleted" "
/> >
<span v-else class="text-muted-foreground text-xs"></span> <SelectValue placeholder="Select field..." />
</TableCell> </SelectTrigger>
<TableCell> <SelectContent>
<Select <SelectGroup>
v-if="row.field === 'meta'" <SelectItem
:model-value="(row.options ||= {}).type || 'string'" v-for="f in fieldsForEntity(row.entity)"
@update:model-value="(val) => (row.options ||= {}).type = val" :key="f"
:disabled="isCompleted" :value="f"
> >{{ f }}</SelectItem
<SelectTrigger class="h-8 text-xs"> >
<SelectValue /> </SelectGroup>
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
<SelectGroup> </TableCell>
<SelectItem value="string">string</SelectItem> <TableCell>
<SelectItem value="number">number</SelectItem> <Input
<SelectItem value="date">date</SelectItem> v-if="row.field === 'meta'"
<SelectItem value="boolean">boolean</SelectItem> v-model="(row.options ||= {}).key"
</SelectGroup> type="text"
</SelectContent> class="h-8 text-xs"
</Select> placeholder="e.g. monthly_rent"
<span v-else class="text-muted-foreground text-xs"></span> :disabled="isCompleted"
</TableCell> />
<TableCell> <span v-else class="text-muted-foreground text-xs"></span>
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted"> </TableCell>
<SelectTrigger class="h-8 text-xs"> <TableCell>
<SelectValue /> <Select
</SelectTrigger> v-if="row.field === 'meta'"
<SelectContent> :model-value="(row.options ||= {}).type || 'string'"
<SelectGroup> @update:model-value="(val) => ((row.options ||= {}).type = val)"
<SelectItem value="none">None</SelectItem> :disabled="isCompleted"
<SelectItem value="trim">Trim</SelectItem> >
<SelectItem value="upper">Uppercase</SelectItem> <SelectTrigger class="h-8 text-xs">
<SelectItem value="lower">Lowercase</SelectItem> <SelectValue />
</SelectGroup> </SelectTrigger>
</SelectContent> <SelectContent>
</Select> <SelectGroup>
</TableCell> <SelectItem value="string">string</SelectItem>
<TableCell> <SelectItem value="number">number</SelectItem>
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted"> <SelectItem value="date">date</SelectItem>
<SelectTrigger class="h-8 text-xs"> <SelectItem value="boolean">boolean</SelectItem>
<SelectValue /> </SelectGroup>
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
<SelectGroup> <span v-else class="text-muted-foreground text-xs"></span>
<SelectItem value="keyref">Keyref</SelectItem> </TableCell>
<SelectItem value="both">Both</SelectItem> <TableCell>
<SelectItem value="insert">Insert only</SelectItem> <Select
<SelectItem value="update">Update only</SelectItem> :model-value="row.transform || 'none'"
</SelectGroup> @update:model-value="
</SelectContent> (val) => (row.transform = val === 'none' ? '' : val)
</Select> "
</TableCell> :disabled="isCompleted"
<TableCell class="text-center"> >
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" /> <SelectTrigger class="h-8 text-xs">
</TableCell> <SelectValue />
</TableRow> </SelectTrigger>
</TableBody> <SelectContent>
</Table> <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> </ScrollArea>
</div> </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> <Badge variant="default" class="bg-emerald-600">Saved</Badge>
<span>{{ mappingSavedCount }} mappings saved</span> <span>{{ mappingSavedCount }} mappings saved</span>
</div> </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"> <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>
</div> </div>
</template> </template>
+7 -1
View File
@@ -67,7 +67,7 @@ const props = defineProps({
completed_mode: { type: Boolean, default: false }, 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) { function openViewer(doc) {
const kind = classifyDocument(doc); const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract"); const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -85,6 +85,8 @@ function openViewer(doc) {
viewer.open = true; viewer.open = true;
viewer.src = url; viewer.src = url;
viewer.title = doc.original_name || doc.name; viewer.title = doc.original_name || doc.name;
viewer.mimeType = doc.mime_type || "";
viewer.filename = doc.original_name || doc.name || "";
} else { } else {
const url = const url =
isContractDoc && doc.contract_uuid isContractDoc && doc.contract_uuid
@@ -102,6 +104,8 @@ function openViewer(doc) {
function closeViewer() { function closeViewer() {
viewer.open = false; viewer.open = false;
viewer.src = ""; viewer.src = "";
viewer.mimeType = "";
viewer.filename = "";
} }
function formatAmount(val) { function formatAmount(val) {
@@ -610,6 +614,8 @@ const clientSummary = computed(() => {
:show="viewer.open" :show="viewer.open"
:src="viewer.src" :src="viewer.src"
:title="viewer.title" :title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer" @close="closeViewer"
/> />
<ActivityDrawer <ActivityDrawer
@@ -20,6 +20,7 @@ import {
} from "@/Components/ui/dialog"; } from "@/Components/ui/dialog";
import InputError from "@/Components/InputError.vue"; import InputError from "@/Components/InputError.vue";
import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next"; import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
defineProps({ defineProps({
sessions: Array, sessions: Array,
@@ -55,60 +56,64 @@ const closeModal = () => {
</script> </script>
<template> <template>
<Card> <AppCard
<CardHeader> 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"> <div class="flex items-center gap-2">
<LogOut class="h-5 w-5 text-muted-foreground" /> <LogOut size="18" />
<CardTitle>Browser Sessions</CardTitle> <CardTitle>Aktivne prijave</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Manage and log out your active sessions on other browsers and devices. Upravljanje in izpis aktivnih prijav no drugih brskalnikih in napravah.
</CardDescription> </CardDescription>
</CardHeader> </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>
<CardContent class="space-y-6"> <div class="flex-1 min-w-0">
<p class="text-sm text-muted-foreground"> <div class="text-sm font-medium">
If necessary, you may log out of all of your other browser sessions across all of {{ session.agent.platform ? session.agent.platform : "Unknown" }} -
your devices. Some of your recent sessions are listed below; however, this list {{ session.agent.browser ? session.agent.browser : "Unknown" }}
may not be exhaustive. If you feel your account has been compromised, you should
also update your password.
</p>
<!-- Other Browser Sessions -->
<div v-if="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="flex-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> </div>
<div class="text-xs text-muted-foreground mt-1">
<div class="flex-1 min-w-0"> {{ session.ip_address }}
<div class="text-sm font-medium"> <span
{{ session.agent.platform ? session.agent.platform : "Unknown" }} - v-if="session.is_current_device"
{{ session.agent.browser ? session.agent.browser : "Unknown" }} class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
</div> >
<div class="text-xs text-muted-foreground mt-1"> Ta naprava
{{ session.ip_address }} </span>
<span <span v-else class="ml-1"> · Aktiven {{ session.last_active }} </span>
v-if="session.is_current_device"
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
>
This device
</span>
<span v-else class="ml-1"> · Last active {{ session.last_active }} </span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="flex items-center gap-3"> <!-- 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>
<template #footer>
<div class="flex flex-row gap-1 items-center justify-end w-full">
<Button @click="confirmLogout"> <Button @click="confirmLogout">
<LogOut class="h-4 w-4 mr-2" /> <LogOut class="h-4 w-4 mr-2" />
Log Out Other Browser Sessions Log Out Other Browser Sessions
@@ -122,38 +127,38 @@ const closeModal = () => {
<span>Done.</span> <span>Done.</span>
</div> </div>
</div> </div>
</CardContent> </template>
</AppCard>
<!-- Log Out Other Devices Confirmation Dialog --> <!-- Log Out Other Devices Confirmation Dialog -->
<Dialog :open="confirmingLogout" @update:open="closeModal"> <Dialog :open="confirmingLogout" @update:open="closeModal">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Log Out Other Browser Sessions</DialogTitle> <DialogTitle>Log Out Other Browser Sessions</DialogTitle>
<DialogDescription> <DialogDescription>
Please enter your password to confirm you would like to log out of your other Please enter your password to confirm you would like to log out of your other
browser sessions across all of your devices. browser sessions across all of your devices.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div class="py-4"> <div class="py-4">
<Input <Input
ref="passwordInput" ref="passwordInput"
v-model="form.password" v-model="form.password"
type="password" type="password"
placeholder="Password" placeholder="Password"
autocomplete="current-password" autocomplete="current-password"
@keyup.enter="logoutOtherBrowserSessions" @keyup.enter="logoutOtherBrowserSessions"
/> />
<InputError :message="form.errors.password" class="mt-2" /> <InputError :message="form.errors.password" class="mt-2" />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" @click="closeModal"> Cancel </Button> <Button variant="outline" @click="closeModal"> Cancel </Button>
<Button :disabled="form.processing" @click="logoutOtherBrowserSessions"> <Button :disabled="form.processing" @click="logoutOtherBrowserSessions">
Log Out Other Browser Sessions Log Out Other Browser Sessions
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Card>
</template> </template>
@@ -1,17 +1,24 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from "vue";
import { router, useForm, usePage } from '@inertiajs/vue3'; import { router, useForm, usePage } from "@inertiajs/vue3";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card'; import {
import { Button } from '@/Components/ui/button'; Card,
import { Input } from '@/Components/ui/input'; CardContent,
import { Label } from '@/Components/ui/label'; CardDescription,
import { Badge } from '@/Components/ui/badge'; CardHeader,
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue'; CardTitle,
import InputError from '@/Components/InputError.vue'; } from "@/Components/ui/card";
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from 'lucide-vue-next'; 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({ const props = defineProps({
requiresConfirmation: Boolean, requiresConfirmation: Boolean,
}); });
const page = usePage(); const page = usePage();
@@ -23,284 +30,298 @@ const setupKey = ref(null);
const recoveryCodes = ref([]); const recoveryCodes = ref([]);
const confirmationForm = useForm({ const confirmationForm = useForm({
code: '', code: "",
}); });
const twoFactorEnabled = computed( const twoFactorEnabled = computed(
() => ! enabling.value && page.props.auth.user?.two_factor_enabled, () => !enabling.value && page.props.auth.user?.two_factor_enabled
); );
watch(twoFactorEnabled, () => { watch(twoFactorEnabled, () => {
if (! twoFactorEnabled.value) { if (!twoFactorEnabled.value) {
confirmationForm.reset(); confirmationForm.reset();
confirmationForm.clearErrors(); confirmationForm.clearErrors();
} }
}); });
const enableTwoFactorAuthentication = () => { const enableTwoFactorAuthentication = () => {
enabling.value = true; enabling.value = true;
router.post(route('two-factor.enable'), {}, { router.post(
preserveScroll: true, route("two-factor.enable"),
onSuccess: () => Promise.all([ {},
showQrCode(), {
showSetupKey(), preserveScroll: true,
showRecoveryCodes(), onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
]), onFinish: () => {
onFinish: () => { enabling.value = false;
enabling.value = false; confirming.value = props.requiresConfirmation;
confirming.value = props.requiresConfirmation; },
}, }
}); );
}; };
const showQrCode = () => { const showQrCode = () => {
return axios.get(route('two-factor.qr-code')).then(response => { return axios.get(route("two-factor.qr-code")).then((response) => {
qrCode.value = response.data.svg; qrCode.value = response.data.svg;
}); });
}; };
const showSetupKey = () => { const showSetupKey = () => {
return axios.get(route('two-factor.secret-key')).then(response => { return axios.get(route("two-factor.secret-key")).then((response) => {
setupKey.value = response.data.secretKey; setupKey.value = response.data.secretKey;
}); });
} };
const showRecoveryCodes = () => { const showRecoveryCodes = () => {
return axios.get(route('two-factor.recovery-codes')).then(response => { return axios.get(route("two-factor.recovery-codes")).then((response) => {
recoveryCodes.value = response.data; recoveryCodes.value = response.data;
}); });
}; };
const confirmTwoFactorAuthentication = () => { const confirmTwoFactorAuthentication = () => {
confirmationForm.post(route('two-factor.confirm'), { confirmationForm.post(route("two-factor.confirm"), {
errorBag: "confirmTwoFactorAuthentication", errorBag: "confirmTwoFactorAuthentication",
preserveScroll: true, preserveScroll: true,
preserveState: true, preserveState: true,
onSuccess: () => { onSuccess: () => {
confirming.value = false; confirming.value = false;
qrCode.value = null; qrCode.value = null;
setupKey.value = null; setupKey.value = null;
}, },
}); });
}; };
const regenerateRecoveryCodes = () => { const regenerateRecoveryCodes = () => {
axios axios.post(route("two-factor.recovery-codes")).then(() => showRecoveryCodes());
.post(route('two-factor.recovery-codes'))
.then(() => showRecoveryCodes());
}; };
const disableTwoFactorAuthentication = () => { const disableTwoFactorAuthentication = () => {
disabling.value = true; disabling.value = true;
router.delete(route('two-factor.disable'), { router.delete(route("two-factor.disable"), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
disabling.value = false; disabling.value = false;
confirming.value = false; confirming.value = false;
}, },
}); });
}; };
const copyToClipboard = async (text) => { const copyToClipboard = async (text) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
} catch (err) { } catch (err) {
console.error('Failed to copy:', err); console.error("Failed to copy:", err);
} }
}; };
</script> </script>
<template> <template>
<Card> <AppCard
<CardHeader> title=""
<div class="flex items-center gap-2"> padding="none"
<Shield class="h-5 w-5 text-muted-foreground" /> class="p-0! gap-0"
<CardTitle>Two Factor Authentication</CardTitle> 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>
<!-- 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" />
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>
<!-- 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>
<!-- QR Code -->
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
<!-- 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>
<CardDescription> </div>
Add additional security to your account using two factor authentication. </div>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6"> <!-- Confirmation Code Input -->
<!-- Status Header --> <div v-if="confirming" class="space-y-2">
<div class="flex items-start gap-3"> <Label for="code">Potrdite kodo</Label>
<div class="flex-1"> <Input
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-semibold flex items-center gap-2"> id="code"
<CheckCircle class="h-5 w-5 text-green-600" /> v-model="confirmationForm.code"
Two factor authentication is enabled type="text"
</h3> name="code"
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-semibold flex items-center gap-2"> inputmode="numeric"
<AlertCircle class="h-5 w-5 text-amber-600" /> autofocus
Finish enabling two factor authentication autocomplete="one-time-code"
</h3> placeholder="Enter 6-digit code"
<h3 v-else class="text-lg font-semibold flex items-center gap-2"> class="max-w-xs"
<Shield class="h-5 w-5 text-muted-foreground" /> @keyup.enter="confirmTwoFactorAuthentication"
Two factor authentication is disabled />
</h3> <InputError :message="confirmationForm.errors.code" class="mt-2" />
<p class="mt-2 text-sm text-muted-foreground"> </div>
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. </div>
</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="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>
</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>
<!-- QR Code & Setup --> <!-- Confirm -->
<div v-if="twoFactorEnabled" class="space-y-6"> <template v-else>
<div v-if="qrCode" class="space-y-4"> <ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
<div class="rounded-lg border bg-muted/50 p-4"> <Button v-if="confirming" type="button" :disabled="enabling">
<p v-if="confirming" class="text-sm font-medium mb-4"> <CheckCircle class="h-4 w-4 mr-2" />
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. Confirm
</p> </Button>
<p v-else class="text-sm text-muted-foreground mb-4"> </ConfirmsPassword>
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
</p>
<!-- QR Code --> <!-- Regenerate Recovery Codes -->
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" /> <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>
<!-- Setup Key --> <!-- Show Recovery Codes -->
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border"> <ConfirmsPassword @confirmed="showRecoveryCodes">
<div class="flex items-center justify-between gap-2"> <Button
<div class="flex-1"> v-if="recoveryCodes.length === 0 && !confirming"
<Label class="text-xs text-muted-foreground">Setup Key</Label> type="button"
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p> variant="outline"
</div> >
<Button <Key class="h-4 w-4 mr-2" />
type="button" Show Recovery Codes
variant="ghost" </Button>
size="sm" </ConfirmsPassword>
@click="copyToClipboard(setupKey)"
>
<Copy class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<!-- Confirmation Code Input --> <!-- Cancel/Disable -->
<div v-if="confirming" class="space-y-2"> <ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Label for="code">Confirmation Code</Label> <Button
<Input v-if="confirming"
id="code" type="button"
v-model="confirmationForm.code" variant="outline"
type="text" :disabled="disabling"
name="code" >
inputmode="numeric" Cancel
autofocus </Button>
autocomplete="one-time-code" </ConfirmsPassword>
placeholder="Enter 6-digit code"
class="max-w-xs"
@keyup.enter="confirmTwoFactorAuthentication"
/>
<InputError :message="confirmationForm.errors.code" class="mt-2" />
</div>
</div>
<!-- Recovery Codes --> <ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<div v-if="recoveryCodes.length > 0 && ! confirming" class="space-y-4"> <Button
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"> v-if="!confirming"
<div class="flex items-start gap-2"> type="button"
<Key class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" /> variant="destructive"
<p class="text-sm font-medium text-amber-900 dark:text-amber-100"> :disabled="disabling"
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> Disable
</div> </Button>
</div> </ConfirmsPassword>
</template>
<div class="rounded-lg border bg-muted p-4"> </div>
<div class="grid grid-cols-2 gap-2 font-mono text-sm"> </template>
<div v-for="code in recoveryCodes" :key="code" class="flex items-center justify-between p-2 bg-background rounded border"> </AppCard>
<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>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-2">
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- Cancel/Disable -->
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button
v-if="confirming"
type="button"
variant="outline"
:disabled="disabling"
>
Cancel
</Button>
</ConfirmsPassword>
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button
v-if="! confirming"
type="button"
variant="destructive"
:disabled="disabling"
>
Disable
</Button>
</ConfirmsPassword>
</template>
</div>
</CardContent>
</Card>
</template> </template>
@@ -1,101 +1,106 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from "vue";
import { useForm } from '@inertiajs/vue3'; import { useForm } from "@inertiajs/vue3";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card'; import { Button } from "@/Components/ui/button";
import { Button } from '@/Components/ui/button'; import { Input } from "@/Components/ui/input";
import { Input } from '@/Components/ui/input'; import { Label } from "@/Components/ui/label";
import { Label } from '@/Components/ui/label'; import InputError from "@/Components/InputError.vue";
import InputError from '@/Components/InputError.vue'; import { CheckCircle, Lock } from "lucide-vue-next";
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 passwordInput = ref(null);
const currentPasswordInput = ref(null); const currentPasswordInput = ref(null);
const form = useForm({ const form = useForm({
current_password: '', current_password: "",
password: '', password: "",
password_confirmation: '', password_confirmation: "",
}); });
const updatePassword = () => { const updatePassword = () => {
form.put(route('user-password.update'), { form.put(route("user-password.update"), {
errorBag: 'updatePassword', errorBag: "updatePassword",
preserveScroll: true, preserveScroll: true,
onSuccess: () => form.reset(), onSuccess: () => form.reset(),
onError: () => { onError: () => {
if (form.errors.password) { if (form.errors.password) {
form.reset('password', 'password_confirmation'); form.reset("password", "password_confirmation");
passwordInput.value.focus(); passwordInput.value.focus();
} }
if (form.errors.current_password) { if (form.errors.current_password) {
form.reset('current_password'); form.reset("current_password");
currentPasswordInput.value.focus(); currentPasswordInput.value.focus();
} }
}, },
}); });
}; };
</script> </script>
<template> <template>
<Card> <AppCard
<form @submit.prevent="updatePassword"> title=""
<CardHeader> padding="none"
<div class="flex items-center gap-2"> class="p-0! gap-0"
<Lock class="h-5 w-5 text-muted-foreground" /> header-class="py-3! px-4 gap-0 text-muted-foreground"
<CardTitle>Update Password</CardTitle> body-class="p-4 border-t"
</div> >
<CardDescription> <template #header>
Ensure your account is using a long, random password to stay secure. <div class="flex items-center gap-2">
</CardDescription> <Lock size="18" />
</CardHeader> <CardTitle>Posodobi geslo</CardTitle>
</div>
<p class="text-sm">
Poskrbite, da vaš račun uporablja dolgo, naključno geslo za varnost.
</p>
</template>
<CardContent class="space-y-6"> <form @submit.prevent="updatePassword" class="space-y-6">
<div class="space-y-2"> <div class="space-y-2">
<Label for="current_password">Current Password</Label> <Label for="current_password">Trenutno geslo</Label>
<Input <Input
id="current_password" id="current_password"
ref="currentPasswordInput" ref="currentPasswordInput"
v-model="form.current_password" v-model="form.current_password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
/> />
<InputError :message="form.errors.current_password" class="mt-2" /> <InputError :message="form.errors.current_password" class="mt-2" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password">New Password</Label> <Label for="password">Novo geslo</Label>
<Input <Input
id="password" id="password"
ref="passwordInput" ref="passwordInput"
v-model="form.password" v-model="form.password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
/> />
<InputError :message="form.errors.password" class="mt-2" /> <InputError :message="form.errors.password" class="mt-2" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password_confirmation">Confirm Password</Label> <Label for="password_confirmation">Potrdi geslo</Label>
<Input <Input
id="password_confirmation" id="password_confirmation"
v-model="form.password_confirmation" v-model="form.password_confirmation"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
/> />
<InputError :message="form.errors.password_confirmation" class="mt-2" /> <InputError :message="form.errors.password_confirmation" class="mt-2" />
</div> </div>
</CardContent> </form>
<CardFooter class="flex items-center justify-between"> <template #footer>
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center justify-between w-full">
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" /> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<span v-if="form.recentlySuccessful">Saved.</span> <CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
</div> <span v-if="form.recentlySuccessful">Shranjeno.</span>
<Button type="submit" :disabled="form.processing"> </div>
Save <Button type="submit" :disabled="form.processing"> Shrani </Button>
</Button> </div>
</CardFooter> </template>
</form> </AppCard>
</Card>
</template> </template>
@@ -1,23 +1,24 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from "vue";
import { Link, router, useForm } from '@inertiajs/vue3'; import { Link, router, useForm } from "@inertiajs/vue3";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card'; import { Button } from "@/Components/ui/button";
import { Button } from '@/Components/ui/button'; import { Input } from "@/Components/ui/input";
import { Input } from '@/Components/ui/input'; import { Label } from "@/Components/ui/label";
import { Label } from '@/Components/ui/label'; import { Avatar, AvatarImage, AvatarFallback } from "@/Components/ui/avatar";
import { Avatar, AvatarImage, AvatarFallback } from '@/Components/ui/avatar'; import InputError from "@/Components/InputError.vue";
import InputError from '@/Components/InputError.vue'; import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from "lucide-vue-next";
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({ const props = defineProps({
user: Object, user: Object,
}); });
const form = useForm({ const form = useForm({
_method: 'PUT', _method: "PUT",
name: props.user.name, name: props.user.name,
email: props.user.email, email: props.user.email,
photo: null, photo: null,
}); });
const verificationLinkSent = ref(null); const verificationLinkSent = ref(null);
@@ -25,189 +26,188 @@ const photoPreview = ref(null);
const photoInput = ref(null); const photoInput = ref(null);
const updateProfileInformation = () => { const updateProfileInformation = () => {
if (photoInput.value) { if (photoInput.value) {
form.photo = photoInput.value.files[0]; form.photo = photoInput.value.files[0];
} }
form.post(route('user-profile-information.update'), { form.post(route("user-profile-information.update"), {
errorBag: 'updateProfileInformation', errorBag: "updateProfileInformation",
preserveScroll: true, preserveScroll: true,
onSuccess: () => clearPhotoFileInput(), onSuccess: () => clearPhotoFileInput(),
}); });
}; };
const sendEmailVerification = () => { const sendEmailVerification = () => {
verificationLinkSent.value = true; verificationLinkSent.value = true;
}; };
const selectNewPhoto = () => { const selectNewPhoto = () => {
photoInput.value.click(); photoInput.value.click();
}; };
const updatePhotoPreview = () => { 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) => { reader.onload = (e) => {
photoPreview.value = e.target.result; photoPreview.value = e.target.result;
}; };
reader.readAsDataURL(photo); reader.readAsDataURL(photo);
}; };
const deletePhoto = () => { const deletePhoto = () => {
router.delete(route('current-user-photo.destroy'), { router.delete(route("current-user-photo.destroy"), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
photoPreview.value = null; photoPreview.value = null;
clearPhotoFileInput(); clearPhotoFileInput();
}, },
}); });
}; };
const clearPhotoFileInput = () => { const clearPhotoFileInput = () => {
if (photoInput.value?.value) { if (photoInput.value?.value) {
photoInput.value.value = null; photoInput.value.value = null;
} }
}; };
</script> </script>
<template> <template>
<Card> <AppCard
<form @submit.prevent="updateProfileInformation"> title=""
<CardHeader> padding="none"
<div class="flex items-center gap-2"> class="p-0! gap-0"
<User class="h-5 w-5 text-muted-foreground" /> header-class="py-3! px-4 gap-0 text-muted-foreground"
<CardTitle>Profile Information</CardTitle> body-class="p-4 border-t"
</div> >
<CardDescription> <template #header>
Update your account's profile information and email address. <div class="flex items-center gap-2">
</CardDescription> <User size="18" />
</CardHeader> <CardTitle>Informacije profila</CardTitle>
</div>
<p class="text-sm">Posodobite informacije vašega profila in e-poštni naslov.</p>
</template>
<CardContent class="space-y-6"> <form @submit.prevent="updateProfileInformation" class="space-y-6">
<!-- Profile Photo --> <!-- Profile Photo -->
<div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4"> <div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4">
<input <input
id="photo" id="photo"
ref="photoInput" ref="photoInput"
type="file" type="file"
class="hidden" class="hidden"
accept="image/*" accept="image/*"
@change="updatePhotoPreview" @change="updatePhotoPreview"
> />
<Label for="photo">Photo</Label> <Label for="photo">Fotografija</Label>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Current/Preview Photo --> <!-- Current/Preview Photo -->
<Avatar class="h-20 w-20"> <Avatar class="h-20 w-20">
<AvatarImage <AvatarImage v-if="photoPreview" :src="photoPreview" :alt="user.name" />
v-if="photoPreview" <AvatarImage v-else :src="user.profile_photo_url" :alt="user.name" />
:src="photoPreview" <AvatarFallback>
:alt="user.name" <User class="h-8 w-8" />
/> </AvatarFallback>
<AvatarImage </Avatar>
v-else
:src="user.profile_photo_url"
:alt="user.name"
/>
<AvatarFallback>
<User class="h-8 w-8" />
</AvatarFallback>
</Avatar>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
@click.prevent="selectNewPhoto" @click.prevent="selectNewPhoto"
> >
<Camera class="h-4 w-4 mr-2" /> <Camera class="h-4 w-4 mr-2" />
Select Photo Izberi fotografijo
</Button> </Button>
<Button <Button
v-if="user.profile_photo_path" v-if="user.profile_photo_path"
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
@click.prevent="deletePhoto" @click.prevent="deletePhoto"
> >
<Trash2 class="h-4 w-4 mr-2" /> <Trash2 class="h-4 w-4 mr-2" />
Remove Odstrani
</Button> </Button>
</div> </div>
</div> </div>
<InputError :message="form.errors.photo" class="mt-2" /> <InputError :message="form.errors.photo" class="mt-2" />
</div> </div>
<!-- Name --> <!-- Name -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="name">Name</Label> <Label for="name">Ime</Label>
<Input <Input id="name" v-model="form.name" type="text" required autocomplete="name" />
id="name" <InputError :message="form.errors.name" class="mt-2" />
v-model="form.name" </div>
type="text"
required
autocomplete="name"
/>
<InputError :message="form.errors.name" class="mt-2" />
</div>
<!-- Email --> <!-- Email -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="email">Email</Label> <Label for="email">E-pošta</Label>
<Input <Input
id="email" id="email"
v-model="form.email" v-model="form.email"
type="email" type="email"
required required
autocomplete="username" autocomplete="username"
/> />
<InputError :message="form.errors.email" class="mt-2" /> <InputError :message="form.errors.email" class="mt-2" />
<!-- Email Verification --> <!-- 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
<div class="flex items-start gap-2"> v-if="
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" /> $page.props.jetstream.hasEmailVerification && user.email_verified_at === null
<div class="flex-1 text-sm"> "
<p class="text-amber-800 dark:text-amber-200"> class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950"
Your email address is unverified. >
<Link <div class="flex items-start gap-2">
:href="route('verification.send')" <AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
method="post" <div class="flex-1 text-sm">
as="button" <p class="text-amber-800 dark:text-amber-200">
class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium" Vaš e-poštni naslov ni potrjen.
@click.prevent="sendEmailVerification" <Link
> :href="route('verification.send')"
Click here to re-send the verification email. method="post"
</Link> as="button"
</p> class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium"
<div v-show="verificationLinkSent" class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400"> @click.prevent="sendEmailVerification"
<CheckCircle class="h-4 w-4" /> >
<span>A new verification link has been sent to your email address.</span> Kliknite tukaj za ponovno pošiljanje potrditvenega e-sporočila.
</div> </Link>
</div> </p>
</div> <div
</div> v-show="verificationLinkSent"
</div> class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400"
</CardContent> >
<CheckCircle class="h-4 w-4" />
<span
>Nova povezava za potrditev je bila poslana na vaš e-poštni
naslov.</span
>
</div>
</div>
</div>
</div>
</div>
</form>
<CardFooter class="flex items-center justify-between"> <template #footer>
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center justify-between w-full">
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" /> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<span v-if="form.recentlySuccessful">Saved.</span> <CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
</div> <span v-if="form.recentlySuccessful">Shranjeno.</span>
<Button type="submit" :disabled="form.processing"> </div>
Save <Button type="submit" :disabled="form.processing"> Shrani </Button>
</Button> </div>
</CardFooter> </template>
</form> </AppCard>
</Card>
</template> </template>
+82 -1
View File
@@ -1,10 +1,11 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3"; import { Link, router, useForm, usePage } from "@inertiajs/vue3";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import axios from "axios"; import axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DialogModal from "@/Components/DialogModal.vue"; import DialogModal from "@/Components/DialogModal.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
@@ -30,6 +31,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card"; import { CardTitle } from "@/Components/ui/card";
import { toNumber } from "lodash";
const props = defineProps({ const props = defineProps({
segment: Object, segment: Object,
@@ -63,6 +65,14 @@ const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
const contractTable = ref(null);
const selectedRows = ref([]);
const showConfirmDialog = ref(false);
const archiveForm = useForm({
contracts: [],
reactivate: false,
});
const hasActiveFilters = computed(() => { const hasActiveFilters = computed(() => {
return Boolean(search.value?.trim()) || Boolean(selectedClient.value); return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
}); });
@@ -78,6 +88,13 @@ const appliedFilterCount = computed(() => {
return count; return count;
}); });
function handleSelectionChange(selectedKeys) {
selectedRows.value = selectedKeys.map((val, i) => {
const nu = toNumber(val);
return props.contracts.data[nu].uuid;
});
}
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1); const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15); const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed( const totalContracts = computed(
@@ -90,6 +107,11 @@ const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value () => exportColumns.value.length === 0 || isExporting.value
); );
const canManageSettings = computed(() => {
const permissions = usePage().props?.auth?.user?.permissions || [];
return permissions.includes("mass-archive");
});
function toggleAllColumns(checked) { function toggleAllColumns(checked) {
exportColumns.value = checked ? columns.map((col) => col.key) : []; exportColumns.value = checked ? columns.map((col) => col.key) : [];
} }
@@ -311,6 +333,36 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null; return asciiMatch?.[1] || null;
} }
function openArchiveModal() {
console.log(selectedRows.value);
if (!selectedRows.value?.length) return;
showConfirmDialog.value = true;
}
function closeConfirmDialog() {
showConfirmDialog.value = false;
}
function submitArchive() {
if (!selectedRows.value?.length) return;
showConfirmDialog.value = false;
archiveForm.contracts = [...selectedRows.value];
archiveForm.reactivate = false;
archiveForm.post(route("contracts.archive-batch"), {
preserveScroll: true,
onSuccess: () => {
selectedRows.value = [];
if (contractTable.value) {
contractTable.value.clearSelection();
}
router.reload({ only: ["contracts"] });
},
});
}
</script> </script>
<template> <template>
@@ -364,10 +416,13 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</template> </template>
<DataTable <DataTable
ref="contractTable"
:columns="columns" :columns="columns"
:data="contracts?.data || []" :data="contracts?.data || []"
:meta="contracts || {}" :meta="contracts || {}"
route-name="segments.show" route-name="segments.show"
:enable-row-selection="canManageSettings"
@selection:change="handleSelectionChange"
:route-params="{ segment: segment?.id ?? segment }" :route-params="{ segment: segment?.id ?? segment }"
:only-props="['contracts']" :only-props="['contracts']"
:page-size="contracts?.per_page ?? 15" :page-size="contracts?.per_page ?? 15"
@@ -500,6 +555,17 @@ function extractFilenameFromHeaders(headers) {
</Button> </Button>
</div> </div>
</template> </template>
<template #toolbar-actions="{ table }">
<Button
v-if="canManageSettings && table?.getSelectedRowModel()?.rows?.length > 0"
variant="destructive"
size="sm"
class="gap-2"
@click="openArchiveModal"
>
Arhiviraj ({{ table.getSelectedRowModel().rows.length }})
</Button>
</template>
<template #cell-client_case="{ row }"> <template #cell-client_case="{ row }">
<Link <Link
@@ -541,6 +607,21 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</div> </div>
<ConfirmDialog
:show="showConfirmDialog"
title="Arhiviraj pogodbe"
:message="`Ali ste prepričani, da želite arhivirati ${
selectedRows?.length || 0
} pogodb${
selectedRows?.length === 1 ? 'o' : ''
}? Arhivirane pogodbe bodo odstranjene iz aktivnih segmentov.`"
confirm-text="Arhiviraj"
cancel-text="Prekliči"
:danger="true"
@close="closeConfirmDialog"
@confirm="submitArchive"
/>
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog"> <DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title> <template #title>
<div class="space-y-1"> <div class="space-y-1">
@@ -265,28 +265,30 @@ const update = () => {
// Transform actions from array of IDs to array of objects // Transform actions from array of IDs to array of objects
const actionsPayload = form.actions const actionsPayload = form.actions
.map(id => { .map((id) => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id); const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
if (!action) { if (!action) {
console.warn('Action not found for id:', id); console.warn("Action not found for id:", id);
return null; return null;
} }
return { id: action.id, name: action.name }; return { id: action.id, name: action.name };
}) })
.filter(Boolean); // Remove null entries .filter(Boolean); // Remove null entries
form.transform((data) => ({ form
...data, .transform((data) => ({
actions: actionsPayload ...data,
})).put(route("settings.decisions.update", { id: form.id }), { actions: actionsPayload,
onSuccess: () => { }))
closeEditDrawer(); .put(route("settings.decisions.update", { id: form.id }), {
}, onSuccess: () => {
onError: (errors) => { closeEditDrawer();
// preserve server errors for display },
scrollToFirstEventError(form.errors, "edit"); onError: (errors) => {
}, // preserve server errors for display
}); scrollToFirstEventError(form.errors, "edit");
},
});
}; };
const store = () => { const store = () => {
@@ -299,27 +301,29 @@ const store = () => {
// Transform actions from array of IDs to array of objects // Transform actions from array of IDs to array of objects
const actionsPayload = createForm.actions const actionsPayload = createForm.actions
.map(id => { .map((id) => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id); const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
if (!action) { if (!action) {
console.warn('Action not found for id:', id); console.warn("Action not found for id:", id);
return null; return null;
} }
return { id: action.id, name: action.name }; return { id: action.id, name: action.name };
}) })
.filter(Boolean); // Remove null entries .filter(Boolean); // Remove null entries
createForm.transform((data) => ({ createForm
...data, .transform((data) => ({
actions: actionsPayload ...data,
})).post(route("settings.decisions.store"), { actions: actionsPayload,
onSuccess: () => { }))
closeCreateDrawer(); .post(route("settings.decisions.store"), {
}, onSuccess: () => {
onError: () => { closeCreateDrawer();
scrollToFirstEventError(createForm.errors, "create"); },
}, onError: () => {
}); scrollToFirstEventError(createForm.errors, "create");
},
});
}; };
function validateEventsClientSide(events) { function validateEventsClientSide(events) {
@@ -665,7 +669,7 @@ const destroyDecision = () => {
</div> </div>
<div class="flex items-center gap-2 self-end"> <div class="flex items-center gap-2 self-end">
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<Checkbox v-model:checked="ev.active" /> <Checkbox v-model="ev.active" />
Aktivno Aktivno
</label> </label>
<Button <Button
@@ -703,7 +707,7 @@ const destroyDecision = () => {
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<label class="flex items-center gap-2 text-sm mt-6"> <label class="flex items-center gap-2 text-sm mt-6">
<Checkbox v-model:checked="ev.config.deactivate_previous" /> <Checkbox v-model="ev.config.deactivate_previous" />
Deaktiviraj prejšnje Deaktiviraj prejšnje
</label> </label>
</div> </div>
+19 -4
View File
@@ -1,13 +1,21 @@
<script setup> <script setup>
import { ref } from "vue"; import { ref, watch } from "vue";
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue"; import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import DataTableExample from "../Examples/DataTableExample.vue"; import DataTableExample from "../Examples/DataTableExample.vue";
import { useForm } from "@inertiajs/vue3";
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
const props = defineProps({ const props = defineProps({
example: { type: String, default: "Demo" }, example: { type: String, default: "Demo" },
}); });
const checkboxValue = ref(false);
const testForm = useForm({
allowed: false,
});
// Dummy columns // Dummy columns
const columns = [ const columns = [
{ key: "id", label: "ID", sortable: true, class: "w-16" }, { key: "id", label: "ID", sortable: true, class: "w-16" },
@@ -53,10 +61,17 @@ function onRowClick(row) {
// no-op demo; could show toast or details // no-op demo; could show toast or details
console.debug("Row clicked:", row); console.debug("Row clicked:", row);
} }
watch(
() => testForm.allowed,
(newVal) => {
console.log(newVal);
}
);
</script> </script>
<template> <template>
<AppLayout>
<DataTableExample></DataTableExample> <Checkbox v-model:checked="testForm.allowed" />
</AppLayout>
</template> </template>
+14 -2
View File
@@ -203,7 +203,14 @@
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id') ->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id') ->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id')
->leftJoin('emails', 'person.id', '=', 'emails.person_id') ->leftJoin('emails', 'person.id', '=', 'emails.person_id')
->select('person.*', 'client_cases.uuid as case_uuid', 'client_cases.id as case_id') ->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
->select(
'person.*',
'client_cases.uuid as case_uuid',
'client_cases.id as case_id',
'client_person.full_name as client_full_name'
)
->limit($request->input('limit')); ->limit($request->input('limit'));
}) })
->get(); ->get();
@@ -215,6 +222,8 @@
$contractCases = \App\Models\Contract::query() $contractCases = \App\Models\Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->join('person', 'client_cases.person_id', '=', 'person.id') ->join('person', 'client_cases.person_id', '=', 'person.id')
->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
->leftJoin('contract_segment', function ($j) { ->leftJoin('contract_segment', function ($j) {
$j->on('contract_segment.contract_id', '=', 'contracts.id') $j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true); ->where('contract_segment.active', true);
@@ -227,9 +236,10 @@
'client_cases.uuid as case_uuid', 'client_cases.uuid as case_uuid',
'client_cases.id as case_id', 'client_cases.id as case_id',
'contracts.reference as contract_reference', 'contracts.reference as contract_reference',
'client_person.full_name as client_full_name',
\DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments") \DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments")
) )
->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference') ->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference', 'client_person.full_name')
->limit($limit) ->limit($limit)
->get(); ->get();
@@ -324,6 +334,7 @@
Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show'); Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment'); Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive'); Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive');
Route::post('contracts/archive-batch', [ClientCaseContoller::class, 'archiveBatch'])->name('contracts.archive-batch')->middleware('permission:mass-archive');
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store'); Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
Route::post('client-cases/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson'); Route::post('client-cases/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson');
// client-case / contract // client-case / contract
@@ -452,6 +463,7 @@
Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows'); Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows');
Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv'); Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv');
Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview'); Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview');
Route::get('imports/{import}/download', [ImportController::class, 'download'])->name('imports.download');
Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts'); Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts');
Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options'); Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options');
// Generic simulation endpoint (new) provides projected effects for first N rows regardless of payments template // Generic simulation endpoint (new) provides projected effects for first N rows regardless of payments template
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\Import;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
it('downloads the original import file', function () {
// Create a test file
$uuid = (string) Str::uuid();
$disk = 'local';
$path = "imports/{$uuid}.csv";
$csv = "email,reference\nalpha@example.com,REF-1\n";
Storage::disk($disk)->put($path, $csv);
// Authenticate a user
$user = User::factory()->create();
Auth::login($user);
// Create import record
$import = Import::create([
'uuid' => $uuid,
'user_id' => $user->id,
'import_template_id' => null,
'client_id' => null,
'source_type' => 'csv',
'file_name' => basename($path),
'original_name' => 'test-import.csv',
'disk' => $disk,
'path' => $path,
'size' => strlen($csv),
'status' => 'uploaded',
'meta' => ['has_header' => true],
]);
// Test download endpoint
$response = test()->get(route('imports.download', ['import' => $import->id]));
$response->assertSuccessful();
expect($response->headers->get('Content-Disposition'))->toContain('test-import.csv');
// Clean up
Storage::disk($disk)->delete($path);
});
it('returns 404 when file does not exist', function () {
// Authenticate a user
$user = User::factory()->create();
Auth::login($user);
// Create import record with non-existent file
$import = Import::create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'import_template_id' => null,
'client_id' => null,
'source_type' => 'csv',
'file_name' => 'missing.csv',
'original_name' => 'missing.csv',
'disk' => 'local',
'path' => 'imports/nonexistent.csv',
'size' => 0,
'status' => 'uploaded',
'meta' => ['has_header' => true],
]);
// Test download endpoint
$response = test()->get(route('imports.download', ['import' => $import->id]));
$response->assertNotFound();
});