Compare commits
37 Commits
6871fe8796
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| 8147fedd04 | |||
| b1c531bb70 | |||
| 9cc1b7072c | |||
| 2968bcf3f8 | |||
| ad0f7a7a01 | |||
| 368b0a7cf7 | |||
| aa375ce0da | |||
| 340e16c610 | |||
| 33b236d881 | |||
| fb7704027b | |||
| e5902706f1 | |||
| 229c100cc4 | |||
| 9a4897bf0c | |||
| d779e4d7a1 | |||
| b2a9350d0f | |||
| d64a67cf76 | |||
| 068bbdf583 | |||
| cc4c07717e | |||
| 28f28be1b8 | |||
| 27bdb942ab | |||
| ebf9f29200 | |||
| 7eaab16e30 | |||
| 6a2dd860fa | |||
| 091fb07646 | |||
| 357a254e82 | |||
| aa93c96d31 | |||
| ca8754cd94 | |||
| 8fdc0d6359 | |||
| df6c3133ec | |||
| f646b6530a | |||
| 7fc4520dbf | |||
| f66bbbf842 | |||
| 4f605451e1 | |||
| dc41862afc | |||
| c4d2f6e473 | |||
| 711438d79f | |||
| fb6474ab88 |
@@ -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
|
||||
@@ -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=
|
||||
@@ -0,0 +1,88 @@
|
||||
APP_NAME="Teren App"
|
||||
APP_ENV=production
|
||||
APP_KEY= # Generate with: php artisan key:generate
|
||||
APP_DEBUG=false
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=https://example.com # Your domain
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD= # Generate a strong password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# pgAdmin
|
||||
PGADMIN_EMAIL=admin@example.com
|
||||
PGADMIN_PASSWORD= # Generate a strong password
|
||||
|
||||
# WireGuard VPN (REQUIRED - app is VPN-only)
|
||||
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
|
||||
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
|
||||
|
||||
# Mail (configure as needed)
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PA
|
||||
SCOUT_DRIVER=database
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/cases
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
+10
@@ -25,7 +25,17 @@ yarn-error.log
|
||||
check-*.php
|
||||
test-*.php
|
||||
fix-*.php
|
||||
clean-*.php
|
||||
mark-*.php
|
||||
|
||||
# Development Documentation
|
||||
IMPORT_*.md
|
||||
V2_*.md
|
||||
REPORTS_*.md
|
||||
DEDUPLICATION_*.md
|
||||
|
||||
# Docker Local Testing
|
||||
docker-compose.local.yaml
|
||||
docker-compose.override.yaml
|
||||
.env.local
|
||||
.env.docker
|
||||
+1045
File diff suppressed because it is too large
Load Diff
+83
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||
{
|
||||
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||
|
||||
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $columnLetterMap = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{label: string}>
|
||||
*/
|
||||
public const COLUMN_METADATA = [
|
||||
'reference' => ['label' => 'Referenca'],
|
||||
'customer' => ['label' => 'Stranka'],
|
||||
'address' => ['label' => 'Naslov'],
|
||||
'start' => ['label' => 'Začetek'],
|
||||
'segment' => ['label' => 'Segment'],
|
||||
'balance' => ['label' => 'Stanje'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $columns
|
||||
*/
|
||||
public function __construct(private Builder $query, private array $columns) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedColumns(): array
|
||||
{
|
||||
return array_keys(self::COLUMN_METADATA);
|
||||
}
|
||||
|
||||
public static function columnLabel(string $column): string
|
||||
{
|
||||
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
$formats = [];
|
||||
|
||||
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||
if ($column === 'reference') {
|
||||
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($column === 'start') {
|
||||
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
return $formats;
|
||||
}
|
||||
|
||||
private function resolveValue(Contract $contract, string $column): mixed
|
||||
{
|
||||
return match ($column) {
|
||||
'reference' => $contract->reference,
|
||||
'customer' => optional($contract->clientCase?->person)->full_name,
|
||||
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||
'start' => $this->formatDate($contract->start_date),
|
||||
'segment' => $contract->segments?->first()?->name,
|
||||
'balance' => optional($contract->account)->balance_amount,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function formatDate(?string $date): mixed
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$carbon = Carbon::parse($date);
|
||||
|
||||
return ExcelDate::dateTimeToExcel($carbon);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getColumnLetterMap(): array
|
||||
{
|
||||
if ($this->columnLetterMap !== []) {
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
$letter = 'A';
|
||||
foreach ($this->columns as $column) {
|
||||
$this->columnLetterMap[$letter] = $column;
|
||||
$letter++;
|
||||
}
|
||||
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
public function bindValue(Cell $cell, $value): bool
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
@@ -23,9 +24,19 @@ class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->latest('id')
|
||||
->paginate(25);
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
->where('active', true)
|
||||
@@ -40,6 +51,7 @@ public function index(Request $request): Response
|
||||
->get(['id', 'name', 'content']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
// Provide a lightweight list of recent clients with person names for filtering
|
||||
@@ -58,8 +70,7 @@ public function index(Request $request): Response
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
@@ -312,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
@@ -323,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
@@ -341,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
} else {
|
||||
// Only include contracts that have at least one active, non-excluded segment
|
||||
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||
->from('contract_segment')
|
||||
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||
->where('contract_segment.active', true)
|
||||
->where('segments.exclude', false)
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
);
|
||||
}
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
@@ -390,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->paginate($perPage);
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
$segment = collect($contract->segments)->last();
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
@@ -414,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
// Stranka: the client person
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
@@ -432,12 +453,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $contracts->currentPage(),
|
||||
'last_page' => $contracts->lastPage(),
|
||||
'per_page' => $contracts->perPage(),
|
||||
'total' => $contracts->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -311,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
'contract_uuids' => 'nullable|array',
|
||||
'contract_uuids.*' => 'uuid',
|
||||
'create_for_all_contracts' => 'nullable|boolean',
|
||||
'phone_view' => 'nullable|boolean',
|
||||
'send_auto_mail' => 'sometimes|boolean',
|
||||
'attachment_document_ids' => 'sometimes|array',
|
||||
@@ -318,61 +321,102 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
]);
|
||||
|
||||
$isPhoneView = $attributes['phone_view'] ?? false;
|
||||
$createForAll = $attributes['create_for_all_contracts'] ?? false;
|
||||
$contractUuids = $attributes['contract_uuids'] ?? [];
|
||||
|
||||
// Map contract_uuid to contract_id within the same client case, if provided
|
||||
$contractId = null;
|
||||
if (! empty($attributes['contract_uuid'])) {
|
||||
// Determine which contracts to process
|
||||
$contractIds = [];
|
||||
if ($createForAll && !empty($contractUuids)) {
|
||||
// Get all contract IDs from the provided UUIDs
|
||||
$contracts = Contract::withTrashed()
|
||||
->whereIn('uuid', $contractUuids)
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->get();
|
||||
$contractIds = $contracts->pluck('id')->toArray();
|
||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||
// Single contract mode
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $contractUuids[0])
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->first();
|
||||
if ($contract) {
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
} elseif (!empty($attributes['contract_uuid'])) {
|
||||
// Legacy single contract_uuid support
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $attributes['contract_uuid'])
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->first();
|
||||
if ($contract) {
|
||||
// Archived contracts are allowed: link activity regardless of active flag
|
||||
$contractId = $contract->id;
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
}
|
||||
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
'decision_id' => $attributes['decision_id'],
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
|
||||
if ($isPhoneView && $contractId) {
|
||||
$fieldJob = $contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->where('assigned_user_id', \Auth::id())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($fieldJob) {
|
||||
$fieldJob->update([
|
||||
'added_activity' => true,
|
||||
'last_activity' => $row->created_at,
|
||||
]);
|
||||
|
||||
}
|
||||
// If no contracts specified, create a single activity without contract
|
||||
if (empty($contractIds)) {
|
||||
$contractIds = [null];
|
||||
}
|
||||
|
||||
logger()->info('Activity successfully inserted', $attributes);
|
||||
$createdActivities = [];
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
// Filter attachments to those belonging to the selected contract
|
||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||
->filter()
|
||||
->map(fn ($v) => (int) $v)
|
||||
->values();
|
||||
$validAttachmentIds = collect();
|
||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||
$validAttachmentIds = Document::query()
|
||||
// Disable auto mail if creating activities for multiple contracts
|
||||
if ($sendFlag && count($contractIds) > 1) {
|
||||
$sendFlag = false;
|
||||
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
|
||||
}
|
||||
|
||||
foreach ($contractIds as $contractId) {
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
'decision_id' => $attributes['decision_id'],
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
|
||||
$createdActivities[] = $row;
|
||||
|
||||
if ($isPhoneView && $contractId) {
|
||||
$contract = Contract::find($contractId);
|
||||
if ($contract) {
|
||||
$fieldJob = $contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->where('assigned_user_id', \Auth::id())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($fieldJob) {
|
||||
$fieldJob->update([
|
||||
'added_activity' => true,
|
||||
'last_activity' => $row->created_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
// Filter attachments to those belonging to the selected contract
|
||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||
->filter()
|
||||
->map(fn ($v) => (int) $v)
|
||||
->values();
|
||||
$validAttachmentIds = collect();
|
||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
@@ -383,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
]);
|
||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||
// If template requires contract and user attempted to send, surface a validation message
|
||||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
||||
logger()->warning('Email not queued: required contract is missing for the selected template.');
|
||||
}
|
||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
|
||||
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Do not fail activity creation due to mailing issues
|
||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$activityCount = count($createdActivities);
|
||||
$successMessage = $activityCount > 1
|
||||
? "Successfully created {$activityCount} activities!"
|
||||
: 'Successfully created activity!';
|
||||
|
||||
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
|
||||
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
|
||||
return back(303)->with('success', 'Successful created!')->with('flash_method', 'POST');
|
||||
return back(303)->with('success', $successMessage)->with('flash_method', 'POST');
|
||||
} catch (QueryException $e) {
|
||||
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
||||
|
||||
@@ -1029,6 +1079,156 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive multiple contracts in a batch operation
|
||||
*/
|
||||
public function archiveBatch(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contracts' => 'required|array',
|
||||
'contracts.*' => 'required|uuid|exists:contracts,uuid',
|
||||
'reactivate' => 'boolean',
|
||||
]);
|
||||
|
||||
$reactivate = $validated['reactivate'] ?? false;
|
||||
|
||||
// Get archive setting
|
||||
$setting = \App\Models\ArchiveSetting::query()
|
||||
->where('enabled', true)
|
||||
->whereIn('strategy', ['immediate', 'manual'])
|
||||
->where('reactivate', $reactivate)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $setting) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
}
|
||||
|
||||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||
$successCount = 0;
|
||||
$skippedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['contracts'] as $contractUuid) {
|
||||
try {
|
||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (!$contract->active) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientCase = $contract->clientCase;
|
||||
|
||||
$context = [
|
||||
'contract_id' => $contract->id,
|
||||
'client_case_id' => $clientCase->id,
|
||||
'account_id' => $contract->account->id ?? null,
|
||||
];
|
||||
|
||||
// Execute archive setting
|
||||
$executor->executeSetting($setting, $context, \Auth::id());
|
||||
|
||||
// Transaction for segment updates and activity logging
|
||||
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||
// Create activity log
|
||||
if ($setting->action_id && $setting->decision_id) {
|
||||
$activityData = [
|
||||
'client_case_id' => $clientCase->id,
|
||||
'action_id' => $setting->action_id,
|
||||
'decision_id' => $setting->decision_id,
|
||||
'note' => ($reactivate)
|
||||
? "Ponovno aktivirana pogodba $contract->reference"
|
||||
: "Arhivirana pogodba $contract->reference",
|
||||
];
|
||||
|
||||
try {
|
||||
\App\Models\Activity::create($activityData);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Activity could not be created during batch archive');
|
||||
}
|
||||
}
|
||||
|
||||
// Move to archive segment if specified
|
||||
if ($setting->segment_id) {
|
||||
$segmentId = $setting->segment_id;
|
||||
|
||||
// Deactivate all current segments
|
||||
$contract->segments()
|
||||
->allRelatedIds()
|
||||
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
|
||||
'active' => false,
|
||||
'updated_at' => now(),
|
||||
]));
|
||||
|
||||
// Activate archive segment
|
||||
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||
'active' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$contract->segments()->attach($segmentId, [
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel pending field jobs
|
||||
$contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->update([
|
||||
'cancelled_at' => date('Y-m-d'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$successCount++;
|
||||
} catch (Exception $e) {
|
||||
\Log::error('Error archiving contract in batch', [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors[] = [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
$message = "Archived $successCount contracts";
|
||||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
'details' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $reactivate
|
||||
? "Successfully reactivated $successCount contracts"
|
||||
: "Successfully archived $successCount contracts";
|
||||
|
||||
if ($skippedCount > 0) {
|
||||
$message .= " ($skippedCount already archived)";
|
||||
}
|
||||
|
||||
return back()->with('flash', [
|
||||
'success' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use App\Http\Requests\ExportClientContractsRequest;
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('clients.active', 1)
|
||||
//->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
@@ -47,7 +51,7 @@ public function index(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->paginate($request->integer('per_page', default: 100))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -137,6 +141,7 @@ public function contracts(Client $client, Request $request)
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
@@ -166,6 +171,84 @@ public function contracts(Client $client, Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportContracts(ExportClientContractsRequest $request, Client $client)
|
||||
{
|
||||
$data = $request->validated();
|
||||
$columns = array_values(array_unique($data['columns']));
|
||||
|
||||
$from = $data['from'] ?? null;
|
||||
$to = $data['to'] ?? null;
|
||||
$search = $data['search'] ?? null;
|
||||
$segmentsParam = $data['segments'] ?? null;
|
||||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||
|
||||
$query = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
|
||||
$page = max(1, (int) ($data['page'] ?? 1));
|
||||
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||
$query->forPage($page, $perPage);
|
||||
}
|
||||
|
||||
$filename = $this->buildExportFilename($client);
|
||||
|
||||
return Excel::download(new ClientContractsExport($query, $columns), $filename);
|
||||
}
|
||||
|
||||
private function buildExportFilename(Client $client): string
|
||||
{
|
||||
$datePrefix = now()->format('dmy');
|
||||
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||
|
||||
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
|
||||
}
|
||||
|
||||
private function slugify(?string $value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 'data';
|
||||
}
|
||||
|
||||
return Str::slug($value, '-') ?: 'data';
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ public function index(Request $request)
|
||||
$unassignedClients = $unassignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id');
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
|
||||
$assignedContracts = Contract::query()
|
||||
@@ -98,7 +99,8 @@ public function index(Request $request)
|
||||
$assignedClients = $assignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id');
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Services\CsvImportService;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -184,12 +183,13 @@ public function store(Request $request)
|
||||
}
|
||||
|
||||
// Kick off processing of an import - simple synchronous step for now
|
||||
public function process(Import $import, Request $request, ImportServiceV2 $processor)
|
||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
@@ -202,7 +202,7 @@ public function process(Import $import, Request $request, ImportServiceV2 $proce
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Import processing failed: ' . $e->getMessage(),
|
||||
'message' => 'Import processing failed: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -712,8 +712,6 @@ public function simulatePayments(Import $import, Request $request)
|
||||
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||
* automatically by the simulation service when mappings contain the payment root.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function simulate(Import $import, Request $request)
|
||||
@@ -829,4 +827,19 @@ public function destroy(Request $request, Import $import)
|
||||
|
||||
return back()->with('success', 'Import deleted successfully');
|
||||
}
|
||||
|
||||
// Download the original import file
|
||||
public function download(Import $import)
|
||||
{
|
||||
// Verify file exists
|
||||
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
|
||||
return response()->json([
|
||||
'error' => 'File not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$fileName = $import->original_name ?? 'import_'.$import->uuid;
|
||||
|
||||
return Storage::disk($import->disk)->download($import->path, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public function unread(Request $request)
|
||||
}
|
||||
|
||||
$today = now()->toDateString();
|
||||
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
|
||||
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
|
||||
$search = trim((string) $request->input('search', ''));
|
||||
$clientUuid = trim((string) $request->input('client', ''));
|
||||
$clientId = null;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExportClientContractsRequest extends FormRequest
|
||||
{
|
||||
public const SCOPE_CURRENT = 'current';
|
||||
|
||||
public const SCOPE_ALL = 'all';
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$columnRule = Rule::in(ClientContractsExport::allowedColumns());
|
||||
|
||||
return [
|
||||
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||
'columns' => ['required', 'array', 'min:1'],
|
||||
'columns.*' => ['string', $columnRule],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
'segments' => ['nullable', 'string'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
|
||||
if ($template && $case) {
|
||||
$note = '';
|
||||
if ($log->status === 'sent') {
|
||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
} elseif ($log->status === 'failed') {
|
||||
$note = sprintf(
|
||||
'Št: %s | Telo: %s | Napaka: %s',
|
||||
'Tel: %s | Telo: %s | Napaka: %s',
|
||||
(string) $this->to,
|
||||
(string) $this->content,
|
||||
'SMS ni bil poslan!'
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -46,6 +46,7 @@ class Person extends Model
|
||||
'group_id',
|
||||
'type_id',
|
||||
'user_id',
|
||||
'employer'
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
@@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||
$payload = $this->buildPayloadForAddress($address);
|
||||
$payload['person_id'] = $personId;
|
||||
|
||||
$addressEntity = new \App\Models\Person\PersonAddress;
|
||||
$addressEntity = new PersonAddress;
|
||||
$addressEntity->fill($payload);
|
||||
$addressEntity->save();
|
||||
|
||||
@@ -129,7 +129,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||
|
||||
protected function resolveAddress(string $address, int $personId): mixed
|
||||
{
|
||||
return \App\Models\Person\PersonAddress::where('person_id', $personId)
|
||||
return PersonAddress::where('person_id', $personId)
|
||||
->where('address', $address)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
$existing = Account::query()
|
||||
->where('contract_id', $contractId)
|
||||
->where('reference', $reference)
|
||||
//->where('reference', $reference)
|
||||
->where('active', 1)
|
||||
->first();
|
||||
|
||||
@@ -1655,6 +1656,14 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
$value = $acc[$field] ?? null;
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||
$value = $this->normalizeDecimal($value);
|
||||
// Ensure the normalized value is numeric, otherwise default to 0
|
||||
if ($value === '' || $value === '-' || ! is_numeric($value)) {
|
||||
$value = 0;
|
||||
}
|
||||
}
|
||||
// Convert empty string to 0 for amount fields
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
|
||||
$value = 0;
|
||||
}
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if ($mode === 'keyref') {
|
||||
@@ -1684,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
if ($existing) {
|
||||
// Build non-null changes for account fields
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
// Track balance change
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
// Track balance change - normalize in case DB has malformed data
|
||||
$rawBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawBalance) && $rawBalance !== '') {
|
||||
$rawBalance = $this->normalizeDecimal($rawBalance);
|
||||
}
|
||||
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
|
||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
@@ -1694,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
||||
$rawNewBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
|
||||
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
|
||||
}
|
||||
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
|
||||
if ($newBalance !== $oldBalance) {
|
||||
try {
|
||||
$contractId = $existing->contract_id;
|
||||
@@ -2970,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
|
||||
// Create person if any fields present; ensure required foreign keys
|
||||
if (! empty($p)) {
|
||||
$data = [];
|
||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
|
||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id', 'employer'] as $k) {
|
||||
if (array_key_exists($k, $p)) {
|
||||
$data[$k] = $p[$k];
|
||||
}
|
||||
@@ -2983,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
|
||||
$data['full_name'] = trim($fn.' '.$ln);
|
||||
}
|
||||
}
|
||||
|
||||
// normalise birthday date
|
||||
if (!empty($data['birthday'])) {
|
||||
try {
|
||||
$data['birthday'] = date('Y-m-d', strtotime($data['birthday']));
|
||||
} catch (Exception $e) {
|
||||
Log::warning('ImportProcessor::findOrCreatePersonId ' . $e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
// ensure required group/type ids
|
||||
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
||||
@@ -3159,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||
$addrData['country'] = 'SLO';
|
||||
}
|
||||
|
||||
if (!empty($addrData['city']) && empty($addrData['post_code'])) {
|
||||
if (preg_match('/^\d{3,}\s+/',trim($addrData['city']))) {
|
||||
$cleanStrCity = str($addrData['city'])->squish()->value();
|
||||
$splitCity = preg_split('/\s/', $cleanStrCity, 2);
|
||||
if (count($splitCity) >= 2) {
|
||||
$addrData['post_code'] = $splitCity[0];
|
||||
$addrData['city'] = $splitCity[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compare addresses with all spaces removed to handle whitespace variations
|
||||
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||
/*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||
|
||||
|
||||
$existing = PersonAddress::where('person_id', $personId)
|
||||
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
|
||||
->first();*/
|
||||
|
||||
// Build search query combining address, post_code and city
|
||||
$searchParts = [$addrData['address']];
|
||||
if (!empty($addrData['post_code'])) {
|
||||
$searchParts[] = $addrData['post_code'];
|
||||
}
|
||||
if (!empty($addrData['city'])) {
|
||||
$searchParts[] = $addrData['city'];
|
||||
}
|
||||
|
||||
$searchQuery = implode(' ', $searchParts);
|
||||
// Use fulltext search (GIN index optimized)
|
||||
$existing = PersonAddress::query()->where('person_id', $personId)
|
||||
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
|
||||
->first();
|
||||
|
||||
$applyInsert = [];
|
||||
@@ -3207,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
$data['person_id'] = $personId;
|
||||
$data['country'] = $data['country'] ?? 'SLO';
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
||||
|
||||
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
|
||||
$data['post_code'] = $addrData['post_code'];
|
||||
}
|
||||
|
||||
try {
|
||||
$created = PersonAddress::create($data);
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
'features' => [
|
||||
// Features::termsAndPrivacyPolicy(),
|
||||
// Features::profilePhotos(),
|
||||
Features::api(),
|
||||
// Features::api(),
|
||||
// Features::teams(['invitations' => true]),
|
||||
Features::accountDeletion(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('person', function (Blueprint $table){
|
||||
$table->string('employer', 125)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person', function (Blueprint $table){
|
||||
$table->dropColumn('employer');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add a generated tsvector column for fulltext search
|
||||
DB::statement("
|
||||
ALTER TABLE person_addresses
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('simple',
|
||||
coalesce(address, '') || ' ' ||
|
||||
coalesce(post_code, '') || ' ' ||
|
||||
coalesce(city, '')
|
||||
)
|
||||
) STORED
|
||||
");
|
||||
|
||||
// Create GIN index on the tsvector column for fast fulltext search
|
||||
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table) {
|
||||
$table->dropIndex('person_addresses_search_vector_idx');
|
||||
$table->dropColumn('search_vector');
|
||||
});
|
||||
}
|
||||
};
|
||||
+49
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -14,7 +14,7 @@ public function run(): void
|
||||
'key' => 'person',
|
||||
'canonical_root' => 'person',
|
||||
'label' => 'Person',
|
||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
|
||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
|
||||
'field_aliases' => [
|
||||
'dob' => 'birthday',
|
||||
'date_of_birth' => 'birthday',
|
||||
@@ -30,6 +30,7 @@ public function run(): void
|
||||
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
||||
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
|
||||
],
|
||||
'ui' => ['order' => 1],
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,118 +1,118 @@
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick } from 'vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import InputError from './InputError.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
import SecondaryButton from './SecondaryButton.vue';
|
||||
import TextInput from './TextInput.vue';
|
||||
import { ref, reactive, nextTick } from "vue";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
import SecondaryButton from "./SecondaryButton.vue";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
const emit = defineEmits(['confirmed']);
|
||||
const emit = defineEmits(["confirmed"]);
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirm Password',
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: 'For your security, please confirm your password to continue.',
|
||||
},
|
||||
button: {
|
||||
type: String,
|
||||
default: 'Confirm',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "Confirm Password",
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: "For your security, please confirm your password to continue.",
|
||||
},
|
||||
button: {
|
||||
type: String,
|
||||
default: "Confirm",
|
||||
},
|
||||
});
|
||||
|
||||
const confirmingPassword = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
password: '',
|
||||
error: '',
|
||||
processing: false,
|
||||
password: "",
|
||||
error: "",
|
||||
processing: false,
|
||||
});
|
||||
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const startConfirmingPassword = () => {
|
||||
axios.get(route('password.confirmation')).then(response => {
|
||||
if (response.data.confirmed) {
|
||||
emit('confirmed');
|
||||
} else {
|
||||
confirmingPassword.value = true;
|
||||
axios.get(route("password.confirmation")).then((response) => {
|
||||
if (response.data.confirmed) {
|
||||
emit("confirmed");
|
||||
} else {
|
||||
confirmingPassword.value = true;
|
||||
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
}
|
||||
});
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const confirmPassword = () => {
|
||||
form.processing = true;
|
||||
form.processing = true;
|
||||
|
||||
axios.post(route('password.confirm'), {
|
||||
password: form.password,
|
||||
}).then(() => {
|
||||
form.processing = false;
|
||||
axios
|
||||
.post(route("password.confirm"), {
|
||||
password: form.password,
|
||||
})
|
||||
.then(() => {
|
||||
form.processing = false;
|
||||
|
||||
closeModal();
|
||||
nextTick().then(() => emit('confirmed'));
|
||||
|
||||
}).catch(error => {
|
||||
form.processing = false;
|
||||
form.error = error.response.data.errors.password[0];
|
||||
passwordInput.value.focus();
|
||||
closeModal();
|
||||
nextTick().then(() => emit("confirmed"));
|
||||
})
|
||||
.catch((error) => {
|
||||
form.processing = false;
|
||||
form.error = error.response.data.errors.password[0];
|
||||
passwordInput.value.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
confirmingPassword.value = false;
|
||||
form.password = '';
|
||||
form.error = '';
|
||||
confirmingPassword.value = false;
|
||||
form.password = "";
|
||||
form.error = "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<span @click="startConfirmingPassword">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<DialogModal :show="confirmingPassword" @close="closeModal">
|
||||
<template #title>
|
||||
{{ title }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
{{ content }}
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="confirmPassword"
|
||||
/>
|
||||
|
||||
<InputError :message="form.error" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="confirmPassword"
|
||||
>
|
||||
{{ button }}
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<span>
|
||||
<span @click="startConfirmingPassword">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<DialogModal :show="confirmingPassword" @close="closeModal">
|
||||
<template #title>
|
||||
{{ title }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
{{ content }}
|
||||
|
||||
<div class="mt-4">
|
||||
<Input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="confirmPassword"
|
||||
/>
|
||||
|
||||
<InputError :message="form.error" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="confirmPassword"
|
||||
>
|
||||
{{ button }}
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -462,6 +462,17 @@ function keyOf(row) {
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
clearSelection: () => {
|
||||
table.resetRowSelection();
|
||||
rowSelection.value = {};
|
||||
},
|
||||
getSelectedRows: () => {
|
||||
return Object.keys(rowSelection.value).filter((key) => rowSelection.value[key]);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -6,34 +6,40 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ref, watch } from 'vue';
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Izbriši' },
|
||||
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
|
||||
confirmText: { type: String, default: 'Izbriši' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
title: { type: String, default: "Izbriši" },
|
||||
message: {
|
||||
type: String,
|
||||
default: "Ali ste prepričani, da želite izbrisati ta element?",
|
||||
},
|
||||
confirmText: { type: String, default: "Izbriši" },
|
||||
cancelText: { type: String, default: "Prekliči" },
|
||||
processing: { type: Boolean, default: false },
|
||||
itemName: { type: String, default: null }, // Optional name to show in confirmation
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
||||
const emit = defineEmits(["update:show", "close", "confirm"]);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
open.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
emit("update:show", newVal);
|
||||
if (!newVal) {
|
||||
emit('close');
|
||||
emit("close");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +48,7 @@ const onClose = () => {
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
emit("confirm");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -59,8 +65,13 @@ const onConfirm = () => {
|
||||
<DialogDescription>
|
||||
<div class="flex items-start gap-4 pt-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" />
|
||||
<div
|
||||
class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faTriangleExclamation"
|
||||
class="h-6 w-6 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
@@ -70,9 +81,7 @@ const onConfirm = () => {
|
||||
<p v-if="itemName" class="text-sm font-medium text-gray-900">
|
||||
{{ itemName }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ta dejanje ni mogoče razveljaviti.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
@@ -82,15 +91,10 @@ const onConfirm = () => {
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
:disabled="processing"
|
||||
>
|
||||
<Button variant="destructive" @click="onConfirm" :disabled="processing">
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
<script setup>
|
||||
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { ref, watch } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form'
|
||||
import { Input } from '@/Components/ui/input'
|
||||
import { Textarea } from '@/Components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
|
||||
import { Switch } from '@/Components/ui/switch'
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { ref, watch } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
@@ -17,112 +29,128 @@ const props = defineProps({
|
||||
// Optional list of contracts to allow attaching the document directly to a contract
|
||||
// Each item should have at least: { uuid, reference }
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'uploaded'])
|
||||
});
|
||||
const emit = defineEmits(["close", "uploaded"]);
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
|
||||
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
|
||||
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
|
||||
const ALLOWED_EXTS = [
|
||||
"doc",
|
||||
"docx",
|
||||
"pdf",
|
||||
"txt",
|
||||
"csv",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
];
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1, 'Ime je obvezno'),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
}))
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
name: z.string().min(1, "Ime je obvezno"),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
name: "",
|
||||
description: "",
|
||||
file: null,
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const localError = ref('')
|
||||
const localError = ref("");
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) return
|
||||
localError.value = ''
|
||||
form.resetForm()
|
||||
})
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return;
|
||||
localError.value = "";
|
||||
form.resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
const onFileChange = (e) => {
|
||||
localError.value = ''
|
||||
const f = e.target.files?.[0]
|
||||
localError.value = "";
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) {
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase()
|
||||
const ext = (f.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||
e.target.value = "";
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||
e.target.value = "";
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('file', f)
|
||||
form.setFieldValue("file", f);
|
||||
if (!form.values.name) {
|
||||
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
|
||||
form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submit = form.handleSubmit(async (values) => {
|
||||
localError.value = ''
|
||||
localError.value = "";
|
||||
if (!values.file) {
|
||||
localError.value = 'Prosimo izberite datoteko.'
|
||||
return
|
||||
localError.value = "Prosimo izberite datoteko.";
|
||||
return;
|
||||
}
|
||||
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
|
||||
const ext = (values.file.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
return
|
||||
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||
return;
|
||||
}
|
||||
if (values.file.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
return
|
||||
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('name', values.name)
|
||||
formData.append('description', values.description || '')
|
||||
formData.append('file', values.file)
|
||||
formData.append('is_public', values.is_public ? '1' : '0')
|
||||
const formData = new FormData();
|
||||
formData.append("name", values.name);
|
||||
formData.append("description", values.description || "");
|
||||
formData.append("file", values.file);
|
||||
formData.append("is_public", values.is_public ? "1" : "0");
|
||||
if (values.contract_uuid) {
|
||||
formData.append('contract_uuid', values.contract_uuid)
|
||||
formData.append("contract_uuid", values.contract_uuid);
|
||||
}
|
||||
|
||||
router.post(props.postUrl, formData, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
emit('uploaded')
|
||||
emit('close')
|
||||
form.resetForm()
|
||||
emit("uploaded");
|
||||
emit("close");
|
||||
form.resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Set form errors if any
|
||||
if (errors.name) form.setFieldError('name', errors.name)
|
||||
if (errors.description) form.setFieldError('description', errors.description)
|
||||
if (errors.file) form.setFieldError('file', errors.file)
|
||||
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
|
||||
if (errors.name) form.setFieldError("name", errors.name);
|
||||
if (errors.description) form.setFieldError("description", errors.description);
|
||||
if (errors.file) form.setFieldError("file", errors.file);
|
||||
if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const close = () => emit('close')
|
||||
const close = () => emit("close");
|
||||
|
||||
const onConfirm = () => {
|
||||
submit()
|
||||
}
|
||||
submit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -137,7 +165,11 @@ const onConfirm = () => {
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid">
|
||||
<FormField
|
||||
v-if="props.contracts && props.contracts.length"
|
||||
v-slot="{ value, handleChange }"
|
||||
name="contract_uuid"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>Pripiši k</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
@@ -148,11 +180,7 @@ const onConfirm = () => {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Primer</SelectItem>
|
||||
<SelectItem
|
||||
v-for="c in props.contracts"
|
||||
:key="c.uuid"
|
||||
:value="c.uuid"
|
||||
>
|
||||
<SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
|
||||
Pogodba: {{ c.reference }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -165,7 +193,11 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Ime</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="doc_name" v-bind="componentField" />
|
||||
<Input
|
||||
id="doc_name"
|
||||
v-bind="componentField"
|
||||
class="w-full max-w-full overflow-hidden text-ellipsis"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -184,29 +216,24 @@ const onConfirm = () => {
|
||||
<FormField v-slot="{ value, handleChange }" name="file">
|
||||
<FormItem>
|
||||
<FormLabel>Datoteka (max 25MB)</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl class="flex w-full">
|
||||
<Input
|
||||
id="doc_file"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
|
||||
class="min-w-0 w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
||||
<div v-if="value" class="text-sm text-gray-600 mt-1">
|
||||
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="is_public">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>Javno</FormLabel>
|
||||
|
||||
@@ -1,30 +1,219 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog'
|
||||
import { Button } from '@/Components/ui/button'
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
src: { type: String, default: '' },
|
||||
title: { type: String, default: 'Dokument' }
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
src: { type: String, default: "" },
|
||||
title: { type: String, default: "Dokument" },
|
||||
mimeType: { type: String, default: "" },
|
||||
filename: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const textContent = ref("");
|
||||
const loading = ref(false);
|
||||
const previewGenerating = ref(false);
|
||||
const previewError = ref("");
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (props.filename) {
|
||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const viewerType = computed(() => {
|
||||
const ext = fileExtension.value;
|
||||
const mime = props.mimeType.toLowerCase();
|
||||
|
||||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
|
||||
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||||
return "docx";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
|
||||
return "image";
|
||||
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||||
|
||||
return "unsupported";
|
||||
});
|
||||
|
||||
const loadTextContent = async () => {
|
||||
if (!props.src || viewerType.value !== "text") return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get(props.src);
|
||||
textContent.value = response.data;
|
||||
} catch (e) {
|
||||
textContent.value = "Napaka pri nalaganju vsebine.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
|
||||
// we poll until it's available.
|
||||
const docxPreviewUrl = ref("");
|
||||
const loadDocxPreview = async () => {
|
||||
if (!props.src || viewerType.value !== "docx") return;
|
||||
|
||||
previewGenerating.value = true;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
|
||||
const maxRetries = 15;
|
||||
const retryDelay = 2000; // 2 seconds between retries
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.head(props.src, { validateStatus: () => true });
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
// Preview is ready
|
||||
docxPreviewUrl.value = props.src;
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
} else if (response.status === 202) {
|
||||
// Preview is being generated, wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
// Other error
|
||||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Max retries reached
|
||||
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||||
previewGenerating.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.show, props.src],
|
||||
([show]) => {
|
||||
if (show && viewerType.value === "text") {
|
||||
loadTextContent();
|
||||
}
|
||||
if (show && viewerType.value === "docx") {
|
||||
loadDocxPreview();
|
||||
}
|
||||
// Reset states when dialog closes
|
||||
if (!show) {
|
||||
previewGenerating.value = false;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
||||
<DialogContent class="max-w-4xl">
|
||||
<DialogContent class="max-w-full xl:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Badge>
|
||||
{{ fileExtension }}
|
||||
</Badge>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="h-[70vh]">
|
||||
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
|
||||
|
||||
<div class="h-[70vh] overflow-auto">
|
||||
<!-- PDF Viewer (browser native) -->
|
||||
<template v-if="viewerType === 'pdf' && props.src">
|
||||
<iframe
|
||||
:src="props.src"
|
||||
class="w-full h-full rounded border"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||||
<template v-else-if="viewerType === 'docx'">
|
||||
<!-- Loading/generating state -->
|
||||
<div
|
||||
v-if="previewGenerating"
|
||||
class="flex flex-col items-center justify-center h-full gap-4"
|
||||
>
|
||||
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||||
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="previewError"
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||
>
|
||||
<span>{{ previewError }}</span>
|
||||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||
Prenesi datoteko
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Preview ready -->
|
||||
<iframe
|
||||
v-else-if="docxPreviewUrl"
|
||||
:src="docxPreviewUrl"
|
||||
class="w-full h-full rounded border"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Image Viewer -->
|
||||
<template v-else-if="viewerType === 'image' && props.src">
|
||||
<img
|
||||
:src="props.src"
|
||||
:alt="props.title"
|
||||
class="max-w-full max-h-full mx-auto object-contain"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Text/CSV/XML Viewer -->
|
||||
<template v-else-if="viewerType === 'text'">
|
||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
|
||||
>{{ textContent }}</pre
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Unsupported -->
|
||||
<template v-else-if="viewerType === 'unsupported'">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||
>
|
||||
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||
Prenesi datoteko
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No source -->
|
||||
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
as-child
|
||||
:default-open="item.isActive"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<a :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -7,12 +7,7 @@ import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -97,7 +92,7 @@ watch(
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: a.description || "",
|
||||
});
|
||||
return;
|
||||
@@ -108,52 +103,51 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.edit && props.id) {
|
||||
const a = props.person.addresses?.find((x) => x.id === props.id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
description: a.description || "",
|
||||
});
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val && props.edit && props.id) {
|
||||
const a = props.person.addresses?.find((x) => x.id === props.id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: a.description || "",
|
||||
});
|
||||
}
|
||||
} else if (val && !props.edit) {
|
||||
resetForm();
|
||||
}
|
||||
} else if (val && !props.edit) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.post(
|
||||
route("person.address.create", props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
router.post(route("person.address.create", props.person), values, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
@@ -223,7 +217,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Naslov"
|
||||
autocomplete="street-address"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -233,7 +232,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Država"
|
||||
autocomplete="country"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -243,7 +247,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Poštna številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Poštna številka"
|
||||
autocomplete="postal-code"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -253,7 +262,22 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Mesto</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Mesto"
|
||||
autocomplete="address-level2"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -85,7 +80,7 @@ const hydrate = () => {
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: a.description || "",
|
||||
});
|
||||
return;
|
||||
@@ -94,10 +89,17 @@ const hydrate = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
watch(() => props.id, () => hydrate(), { immediate: true });
|
||||
watch(() => props.show, (v) => {
|
||||
if (v) hydrate();
|
||||
});
|
||||
watch(
|
||||
() => props.id,
|
||||
() => hydrate(),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) hydrate();
|
||||
}
|
||||
);
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
@@ -157,7 +159,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Naslov"
|
||||
autocomplete="street-address"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -167,7 +174,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Država"
|
||||
autocomplete="country"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -177,7 +189,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Poštna številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Poštna številka"
|
||||
autocomplete="postal-code"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -187,7 +204,22 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Mesto</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Mesto"
|
||||
autocomplete="address-level2"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<Card class="p-2 gap-1" v-for="address in person.addresses" :key="address.id">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
@@ -61,13 +61,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
|
||||
<p class="font-medium text-gray-900 leading-relaxed p-1">
|
||||
{{
|
||||
address.post_code && address.city
|
||||
? `${address.address}, ${address.post_code} ${address.city}`
|
||||
: address.address
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
|
||||
{{ address.description }}
|
||||
</p>
|
||||
</Card>
|
||||
<button
|
||||
v-if="edit"
|
||||
|
||||
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getEmails(person).length">
|
||||
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx">
|
||||
<div class="flex items-center justify-between mb-2" v-if="edit">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
|
||||
<div class="flex items-center justify-between" v-if="edit">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-if="email?.label"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
@@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed">
|
||||
<p class="font-medium text-gray-900 leading-relaxed">
|
||||
{{ email?.value || email?.email || email?.address || "-" }}
|
||||
</p>
|
||||
<p
|
||||
|
||||
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
|
||||
|
||||
<template>
|
||||
<Tabs v-model="activeTab" class="mt-2">
|
||||
<TabsList class="flex w-full bg-white gap-2 p-1">
|
||||
<TabsList class="flex flex-row flex-wrap bg-white gap-2 p-1">
|
||||
<TabsTrigger
|
||||
value="person"
|
||||
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
|
||||
@@ -384,6 +384,7 @@ const switchToTab = (tab) => {
|
||||
</TabsList>
|
||||
<TabsContent value="person" class="py-2">
|
||||
<PersonInfoPersonTab
|
||||
:is-client-case="clientCaseUuid ? true : false"
|
||||
:person="person"
|
||||
:edit="edit"
|
||||
:person-edit="personEdit"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
import { UserEditIcon } from "@/Utilities/Icons";
|
||||
import { Button } from "../ui/button";
|
||||
import { fmtDateDMY } from "@/Utilities/functions";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
isClientCase: { type: Boolean, default: false },
|
||||
edit: { type: Boolean, default: true },
|
||||
personEdit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit']);
|
||||
const emit = defineEmits(["edit"]);
|
||||
|
||||
const getMainAddress = (adresses) => {
|
||||
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
|
||||
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit');
|
||||
emit("edit");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -44,51 +46,126 @@ const handleEdit = () => {
|
||||
>
|
||||
<UserEditIcon size="md" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Primer ref.
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Naziv</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ person.full_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Davčna
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ person.tax_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Emšo</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ person.social_security_number }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p>
|
||||
<div
|
||||
v-if="isClientCase"
|
||||
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"
|
||||
>
|
||||
<div
|
||||
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Naslov
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainAddress(person.addresses) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Telefon
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainPhone(person.phones) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Dat. rojstva
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ fmtDateDMY(person.birthday) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Naslov
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainAddress(person.addresses) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Telefon
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainPhone(person.phones) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3"
|
||||
:class="[isClientCase ? 'md:grid-cols-2' : '']"
|
||||
>
|
||||
<div
|
||||
v-if="isClientCase"
|
||||
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Delodajalec
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ person.employer }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="md:col-span-full rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
:class="[isClientCase ? 'lg:col-span-1' : '']"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Opis</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ person.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
|
||||
import {
|
||||
CircleCheckBigIcon,
|
||||
CircleCheckIcon,
|
||||
EllipsisVertical,
|
||||
MessageSquare,
|
||||
MessageSquareText,
|
||||
} from "lucide-vue-next";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getPhones(person).length">
|
||||
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
@@ -79,8 +85,12 @@ const handleSms = (phone) => emit("sms", phone);
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
|
||||
<p class="font-medium leading-relaxed p-1 flex gap-1 items-center">
|
||||
{{ phone.nu }}
|
||||
<CircleCheckBigIcon color="#3e9392" size="20" v-if="phone.validated" />
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
|
||||
{{ phone.description }}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -301,27 +302,13 @@ const updateSmsFromSelection = async () => {
|
||||
const url = route("clientCase.sms.preview", {
|
||||
client_case: props.clientCaseUuid,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-TOKEN":
|
||||
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
|
||||
"",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_id: form.values.template_id,
|
||||
contract_uuid: form.values.contract_uuid || null,
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
const { data } = await axios.post(url, {
|
||||
template_id: form.values.template_id,
|
||||
contract_uuid: form.values.contract_uuid || null,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||
form.setFieldValue("message", data.content);
|
||||
return;
|
||||
}
|
||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||
form.setFieldValue("message", data.content);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and fallback
|
||||
|
||||
@@ -1,182 +1,205 @@
|
||||
<script setup>
|
||||
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
|
||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
||||
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import DatePicker from "../DatePicker.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
person: Object
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
person: Object,
|
||||
});
|
||||
|
||||
const processingUpdate = ref(false);
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
full_name: z.string().min(1, "Naziv je obvezen."),
|
||||
tax_number: z.string().optional(),
|
||||
social_security_number: z.string().optional(),
|
||||
birthday: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
employer: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
full_name: props.person?.full_name || '',
|
||||
tax_number: props.person?.tax_number || '',
|
||||
social_security_number: props.person?.social_security_number || '',
|
||||
description: props.person?.description || ''
|
||||
full_name: props.person?.full_name || "",
|
||||
tax_number: props.person?.tax_number || "",
|
||||
social_security_number: props.person?.social_security_number || "",
|
||||
birthday: props.person?.birthday || "",
|
||||
description: props.person?.description || "",
|
||||
employer: props.person?.employer || "",
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
setTimeout(() => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
full_name: props.person?.full_name || '',
|
||||
tax_number: props.person?.tax_number || '',
|
||||
social_security_number: props.person?.social_security_number || '',
|
||||
description: props.person?.description || ''
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
full_name: props.person?.full_name || "",
|
||||
tax_number: props.person?.tax_number || "",
|
||||
social_security_number: props.person?.social_security_number || "",
|
||||
birthday: props.person?.birthday || "",
|
||||
description: props.person?.description || "",
|
||||
employer: props.person?.employer || "",
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const updatePerson = async () => {
|
||||
processingUpdate.value = true;
|
||||
const { values } = form;
|
||||
processingUpdate.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.put(
|
||||
route('person.update', props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processingUpdate.value = false;
|
||||
close();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
router.put(route("person.update", props.person), values, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processingUpdate.value = false;
|
||||
close();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(() => {
|
||||
updatePerson();
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
onSubmit();
|
||||
}
|
||||
onSubmit();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<UpdateDialog
|
||||
:show="show"
|
||||
:title="`Posodobi ${person.full_name}`"
|
||||
confirm-text="Shrani"
|
||||
:processing="processingUpdate"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>
|
||||
Oseba
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<UpdateDialog
|
||||
:show="show"
|
||||
:title="`Posodobi ${person.full_name}`"
|
||||
confirm-text="Shrani"
|
||||
:processing="processingUpdate"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title> Oseba </template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="cfullname"
|
||||
type="text"
|
||||
placeholder="Naziv"
|
||||
autocomplete="full-name"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="cfullname"
|
||||
type="text"
|
||||
placeholder="Naziv"
|
||||
autocomplete="full-name"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="ctaxnumber"
|
||||
type="text"
|
||||
placeholder="Davčna številka"
|
||||
autocomplete="tax-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="ctaxnumber"
|
||||
type="text"
|
||||
placeholder="Davčna številka"
|
||||
autocomplete="tax-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem>
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="csocialSecurityNumber"
|
||||
type="text"
|
||||
placeholder="Matična / Emšo"
|
||||
autocomplete="social-security-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem>
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="csocialSecurityNumber"
|
||||
type="text"
|
||||
placeholder="Matična / Emšo"
|
||||
autocomplete="social-security-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="cdescription"
|
||||
placeholder="Opis"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
<FormField v-slot="{ componentField }" name="employer">
|
||||
<FormItem>
|
||||
<FormLabel>Delodajalec</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="cemployer"
|
||||
type="text"
|
||||
placeholder="Delodajalec"
|
||||
autocomplete="employer"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="birthday">
|
||||
<FormItem>
|
||||
<FormLabel>Datum rojstva</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
id="cbirthday"
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
format="dd.MM.yyyy"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea id="cdescription" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
</template>
|
||||
|
||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -101,29 +96,25 @@ const create = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.post(
|
||||
route("person.phone.create", props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
router.post(route("person.phone.create", props.person), values, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(() => {
|
||||
@@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
|
||||
<FormItem>
|
||||
<FormLabel>Številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Številka telefona"
|
||||
autocomplete="tel"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
|
||||
<SelectItem
|
||||
v-for="option in countryOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
|
||||
<SelectItem
|
||||
v-for="option in phoneTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="validated">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
|
||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -108,7 +103,7 @@ function hydrateFromProps() {
|
||||
form.setValues({
|
||||
nu: p.nu || "",
|
||||
country_code: p.country_code ?? 386,
|
||||
type_id: p.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
type_id: p.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: p.description || "",
|
||||
validated: !!p.validated,
|
||||
phone_type: p.phone_type ?? null,
|
||||
@@ -119,8 +114,17 @@ function hydrateFromProps() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
watch(() => props.id, () => hydrateFromProps(), { immediate: true });
|
||||
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
|
||||
watch(
|
||||
() => props.id,
|
||||
() => hydrateFromProps(),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) hydrateFromProps();
|
||||
}
|
||||
);
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
@@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
|
||||
<FormItem>
|
||||
<FormLabel>Številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Številka telefona"
|
||||
autocomplete="tel"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
|
||||
<SelectItem
|
||||
v-for="option in countryOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
|
||||
<SelectItem
|
||||
v-for="option in phoneTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="validated">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
|
||||
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
|
||||
const found = props.items.find((i) => String(i.value) === String(v));
|
||||
return found?.label || v;
|
||||
});
|
||||
if (labels.length <= 3) return labels.join(', ');
|
||||
const firstThree = labels.slice(0, 3).join(', ');
|
||||
if (labels.length <= 3) return labels.join(", ");
|
||||
const firstThree = labels.slice(0, 3).join(", ");
|
||||
const remaining = labels.length - 3;
|
||||
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
||||
});
|
||||
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
|
||||
:variant="chipVariant"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="truncate max-w-[140px]">
|
||||
<span class="truncate max-w-35">
|
||||
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { CalendarIcon, XIcon } from "lucide-vue-next";
|
||||
import { computed, ref } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import { RangeCalendar } from "@/Components/ui/range-calendar";
|
||||
import {
|
||||
DateFormatter,
|
||||
getLocalTimeZone,
|
||||
today,
|
||||
parseDate,
|
||||
CalendarDate,
|
||||
} from "@internationalized/date";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({ start: null, end: null }),
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Izberi datumski obseg",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: "w-[280px]",
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
default: "sl-SI",
|
||||
},
|
||||
numberOfMonths: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
minValue: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
maxValue: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const df = new DateFormatter(props.locale, {
|
||||
dateStyle: "medium",
|
||||
});
|
||||
|
||||
// Check if there's a selected value
|
||||
const hasValue = computed(() => {
|
||||
const val = props.modelValue;
|
||||
return val?.start || val?.end;
|
||||
});
|
||||
|
||||
// Convert string dates to CalendarDate objects for the calendar
|
||||
const calendarValue = computed({
|
||||
get() {
|
||||
const val = props.modelValue;
|
||||
if (!val) return undefined;
|
||||
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
if (val.start) {
|
||||
if (typeof val.start === "string") {
|
||||
start = parseDate(val.start);
|
||||
} else if (val.start instanceof CalendarDate) {
|
||||
start = val.start;
|
||||
}
|
||||
}
|
||||
|
||||
if (val.end) {
|
||||
if (typeof val.end === "string") {
|
||||
end = parseDate(val.end);
|
||||
} else if (val.end instanceof CalendarDate) {
|
||||
end = val.end;
|
||||
}
|
||||
}
|
||||
|
||||
if (!start && !end) return undefined;
|
||||
return { start, end };
|
||||
},
|
||||
set(newValue) {
|
||||
if (!newValue) {
|
||||
emit("update:modelValue", { start: null, end: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert CalendarDate to ISO string (YYYY-MM-DD) for easier handling
|
||||
const result = {
|
||||
start: newValue.start ? newValue.start.toString() : null,
|
||||
end: newValue.end ? newValue.end.toString() : null,
|
||||
};
|
||||
emit("update:modelValue", result);
|
||||
|
||||
// Close popover when both dates are selected
|
||||
if (result.start && result.end) {
|
||||
open.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
const val = calendarValue.value;
|
||||
if (!val?.start) return props.placeholder;
|
||||
|
||||
const startFormatted = df.format(val.start.toDate(getLocalTimeZone()));
|
||||
if (!val.end) return startFormatted;
|
||||
|
||||
const endFormatted = df.format(val.end.toDate(getLocalTimeZone()));
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
});
|
||||
|
||||
function clearValue(event) {
|
||||
event.stopPropagation();
|
||||
emit("update:modelValue", { start: null, end: null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
'justify-start text-left font-normal',
|
||||
!calendarValue?.start && 'text-muted-foreground',
|
||||
buttonClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
|
||||
<span class="truncate flex-1">{{ displayText }}</span>
|
||||
<span
|
||||
v-if="clearable && hasValue && !disabled"
|
||||
class="ml-2 shrink-0 opacity-50 hover:opacity-100 cursor-pointer"
|
||||
@click.stop.prevent="clearValue"
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<RangeCalendar
|
||||
v-model="calendarValue"
|
||||
:locale="locale"
|
||||
:number-of-months="numberOfMonths"
|
||||
:min-value="minValue"
|
||||
:max-value="maxValue"
|
||||
initial-focus
|
||||
@update:start-value="
|
||||
(startDate) => {
|
||||
if (calendarValue?.start?.toString() !== startDate?.toString()) {
|
||||
calendarValue = { start: startDate, end: undefined };
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,178 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "@/Components/ui/sidebar";
|
||||
|
||||
import {
|
||||
AudioWaveform,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Command,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
Map,
|
||||
PieChart,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
} from "lucide-vue-next";
|
||||
import NavMain from "@/Components/app/ui/layout/NavMain.vue";
|
||||
import NavProjects from "@/Components/app/ui/layout/NavProjects.vue";
|
||||
import NavUser from "@/Components/app/ui/layout/NavUser.vue";
|
||||
import TeamSwitcher from "@/Components/app/ui/layout/TeamSwitcher.vue";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
collapsible: "icon",
|
||||
});
|
||||
|
||||
// This is sample data.
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: "Acme Inc",
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: "Enterprise",
|
||||
},
|
||||
{
|
||||
name: "Acme Corp.",
|
||||
logo: AudioWaveform,
|
||||
plan: "Startup",
|
||||
},
|
||||
{
|
||||
name: "Evil Corp.",
|
||||
logo: Command,
|
||||
plan: "Free",
|
||||
},
|
||||
],
|
||||
navMain: [
|
||||
{
|
||||
title: "Playground",
|
||||
url: "#",
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: "History",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Starred",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "#",
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
title: "Genesis",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Explorer",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Quantum",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
url: "#",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{
|
||||
title: "Introduction",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Get Started",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Tutorials",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Changelog",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "General",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Limits",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: "Design Engineering",
|
||||
url: "#",
|
||||
icon: Frame,
|
||||
},
|
||||
{
|
||||
name: "Sales & Marketing",
|
||||
url: "#",
|
||||
icon: PieChart,
|
||||
},
|
||||
{
|
||||
name: "Travel",
|
||||
url: "#",
|
||||
icon: Map,
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar v-bind="props">
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher :teams="data.teams" />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain :items="data.navMain" />
|
||||
<NavProjects :projects="data.projects" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser :user="data.user" />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
as-child
|
||||
:default-open="item.isActive"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<a :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { Folder, Forward, MoreHorizontal, Trash2 } from "lucide-vue-next";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in projects" :key="item.name">
|
||||
<SidebarMenuButton as-child>
|
||||
<a :href="item.url">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.name }}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuAction show-on-hover>
|
||||
<MoreHorizontal />
|
||||
<span class="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-48 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:align="isMobile ? 'end' : 'start'"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder class="text-muted-foreground" />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward class="text-muted-foreground" />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 class="text-muted-foreground" />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton class="text-sidebar-foreground/70">
|
||||
<MoreHorizontal class="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/Components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
import { ChevronsUpDown, Plus } from "lucide-vue-next";
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = defineProps<{
|
||||
teams: {
|
||||
name: string;
|
||||
logo: Component;
|
||||
plan: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
const activeTeam = ref(props.teams[0]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<component :is="activeTeam.logo" class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{{ activeTeam.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs">{{ activeTeam.plan }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="(team, index) in teams"
|
||||
:key="team.name"
|
||||
class="gap-2 p-2"
|
||||
@click="activeTeam = team"
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-sm border">
|
||||
<component :is="team.logo" class="size-3.5 shrink-0" />
|
||||
</div>
|
||||
{{ team.name }}
|
||||
<DropdownMenuShortcut>⌘{{ index + 1 }}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2">
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fieldVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
orientation: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
:data-orientation="orientation"
|
||||
:class="cn(fieldVariants({ orientation }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-content"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="field-description"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
errors: { type: Array, required: false },
|
||||
});
|
||||
|
||||
const content = computed(() => {
|
||||
if (!props.errors || props.errors.length === 0) return null;
|
||||
|
||||
if (props.errors.length === 1 && props.errors[0]?.message) {
|
||||
return props.errors[0].message;
|
||||
}
|
||||
|
||||
return props.errors.some((e) => e?.message) ? props.errors : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="$slots.default || content"
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
:class="cn('text-destructive text-sm font-normal', props.class)"
|
||||
>
|
||||
<slot v-if="$slots.default" />
|
||||
|
||||
<template v-else-if="typeof content === 'string'">
|
||||
{{ content }}
|
||||
</template>
|
||||
|
||||
<ul
|
||||
v-else-if="Array.isArray(content)"
|
||||
class="ml-4 flex list-disc flex-col gap-1"
|
||||
>
|
||||
<li v-for="(error, index) in content" :key="index">
|
||||
{{ error?.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-group"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from '@/Components/ui/label';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
|
||||
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
variant: { type: String, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</legend>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
:data-content="!!$slots.default"
|
||||
:class="
|
||||
cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Separator class="absolute inset-0 top-1/2" />
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-label"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export { default as Field } from "./Field.vue";
|
||||
export { default as FieldContent } from "./FieldContent.vue";
|
||||
export { default as FieldDescription } from "./FieldDescription.vue";
|
||||
export { default as FieldError } from "./FieldError.vue";
|
||||
export { default as FieldGroup } from "./FieldGroup.vue";
|
||||
export { default as FieldLabel } from "./FieldLabel.vue";
|
||||
export { default as FieldLegend } from "./FieldLegend.vue";
|
||||
export { default as FieldSeparator } from "./FieldSeparator.vue";
|
||||
export { default as FieldSet } from "./FieldSet.vue";
|
||||
export { default as FieldTitle } from "./FieldTitle.vue";
|
||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
||||
reference: { type: null, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits([
|
||||
|
||||
@@ -251,19 +251,17 @@ function isActive(patterns) {
|
||||
: 'sticky top-0 h-screen overflow-y-auto',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white"
|
||||
>
|
||||
<div class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar">
|
||||
<Link
|
||||
:href="route('dashboard')"
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ApplicationMark />
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="text-sm font-semibold text-gray-900 transition-opacity"
|
||||
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
|
||||
>
|
||||
Admin
|
||||
Administrator
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -10,19 +10,6 @@ import GlobalSearch from "./Partials/GlobalSearch.vue";
|
||||
import NotificationsBell from "./Partials/NotificationsBell.vue";
|
||||
import ToastContainer from "@/Components/Toast/ToastContainer.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import {
|
||||
faMobileScreenButton,
|
||||
faGaugeHigh,
|
||||
faLayerGroup,
|
||||
faUserGroup,
|
||||
faFolderOpen,
|
||||
faFileImport,
|
||||
faTableList,
|
||||
faFileCirclePlus,
|
||||
faMap,
|
||||
faGear,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { MenuIcon } from "lucide-vue-next";
|
||||
import { SearchIcon } from "lucide-vue-next";
|
||||
import { ChevronDownIcon } from "lucide-vue-next";
|
||||
@@ -310,18 +297,18 @@ function isActive(patterns) {
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-16 px-4 flex items-center justify-between border-b border-sidebar-border bg-sidebar"
|
||||
class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar"
|
||||
>
|
||||
<Link
|
||||
:href="route('dashboard')"
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ApplicationMark />
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
|
||||
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
|
||||
>
|
||||
Teren
|
||||
Aplikacija
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -149,14 +149,14 @@ const closeSearch = () => (searchOpen.value = false);
|
||||
>
|
||||
<Link
|
||||
:href="route('phone.index')"
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ApplicationMark />
|
||||
<span
|
||||
v-if="showLabels"
|
||||
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
|
||||
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
|
||||
>
|
||||
Teren
|
||||
Mobitel
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<script setup>
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import axios from "axios";
|
||||
import { debounce } from "lodash";
|
||||
import { SearchIcon } from "@/Utilities/Icons";
|
||||
import { SearchIcon, XIcon } from "lucide-vue-next";
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
|
||||
@@ -55,203 +65,169 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
</script>
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="fade">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-900/60 to-slate-800/60 backdrop-blur-sm"
|
||||
@click="isOpen = false"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-start justify-center p-4 pt-20 sm:pt-28"
|
||||
@click.self="isOpen = false"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl rounded-2xl border border-white/10 bg-white/80 backdrop-blur-xl shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
|
||||
<DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
|
||||
<div class="p-4 border-b" ref="inputWrap">
|
||||
<div class="relative">
|
||||
<SearchIcon
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
v-model="query"
|
||||
placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
|
||||
class="w-full pl-10 pr-16"
|
||||
/>
|
||||
<button
|
||||
v-if="query"
|
||||
@click="query = ''"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-accent"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
<div
|
||||
v-if="!query"
|
||||
class="p-8 text-sm text-muted-foreground text-center space-y-2"
|
||||
>
|
||||
<p>Začni tipkati za iskanje.</p>
|
||||
<p class="text-xs">
|
||||
Namig: uporabi <Badge variant="secondary" class="font-mono">Ctrl</Badge> +
|
||||
<Badge variant="secondary" class="font-mono">K</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4 p-4">
|
||||
<!-- Clients Results -->
|
||||
<div v-if="result.clients.length">
|
||||
<div
|
||||
class="p-4 border-b border-slate-200/60"
|
||||
ref="inputWrap"
|
||||
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
|
||||
<SearchIcon />
|
||||
</div>
|
||||
<Input
|
||||
v-model="query"
|
||||
placeholder="Išči po naročnikih ali primerih (Ctrl+K za zapiranje)"
|
||||
class="w-full pl-10 pr-16 rounded-xl"
|
||||
/>
|
||||
<button
|
||||
v-if="query"
|
||||
@click="query = ''"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span>Naročniki</span>
|
||||
<Badge variant="secondary">{{ result.clients.length }}</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300"
|
||||
>
|
||||
<div
|
||||
v-if="!query"
|
||||
class="p-8 text-sm text-slate-500 text-center space-y-2"
|
||||
<div class="space-y-1">
|
||||
<Link
|
||||
v-for="client in result.clients"
|
||||
:key="client.client_uuid"
|
||||
:href="route('client.show', { uuid: client.client_uuid })"
|
||||
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-accent transition"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<p>Začni tipkati za iskanje.</p>
|
||||
<p class="text-xs">
|
||||
Namig: uporabi
|
||||
<kbd
|
||||
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+
|
||||
<kbd
|
||||
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
|
||||
>K</kbd
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="divide-y divide-slate-200/70">
|
||||
<div v-if="result.clients.length" class="py-3">
|
||||
<div
|
||||
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
|
||||
>
|
||||
<span>Naročniki</span>
|
||||
<span
|
||||
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
|
||||
>{{ result.clients.length }}</span
|
||||
>
|
||||
</div>
|
||||
<ul role="list" class="px-2 space-y-1">
|
||||
<li v-for="client in result.clients" :key="client.client_uuid">
|
||||
<Link
|
||||
:href="route('client.show', { uuid: client.client_uuid })"
|
||||
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-indigo-50/70 transition"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<span
|
||||
class="shrink-0 w-6 h-6 rounded bg-indigo-100 text-indigo-600 flex items-center justify-center text-[11px] font-semibold group-hover:bg-indigo-200"
|
||||
>C</span
|
||||
>
|
||||
<span
|
||||
class="text-slate-700 group-hover:text-slate-900"
|
||||
>{{ client.full_name }}</span
|
||||
>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="result.client_cases.length" class="py-3">
|
||||
<div
|
||||
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
|
||||
>
|
||||
<span>Primeri</span>
|
||||
<span
|
||||
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
|
||||
>{{ result.client_cases.length }}</span
|
||||
>
|
||||
</div>
|
||||
<ul role="list" class="px-2 space-y-1">
|
||||
<li
|
||||
v-for="clientcase in result.client_cases"
|
||||
:key="clientcase.case_uuid"
|
||||
class="rounded-xl border border-slate-200/70 bg-white/70 px-4 py-3 shadow-sm hover:shadow-md transition flex flex-col gap-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Link
|
||||
:href="
|
||||
route('clientCase.show', {
|
||||
client_case: clientcase.case_uuid,
|
||||
})
|
||||
"
|
||||
class="text-left font-medium hover:underline leading-tight text-slate-800"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
{{ clientcase.full_name }}
|
||||
</Link>
|
||||
<template v-if="clientcase.contract_reference">
|
||||
<span
|
||||
class="font-mono text-[11px] tracking-tight text-indigo-600 bg-indigo-50 border border-indigo-200 rounded px-1.5 py-0.5 whitespace-nowrap shadow-sm"
|
||||
>
|
||||
{{ clientcase.contract_reference }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
clientcase.contract_segments &&
|
||||
clientcase.contract_segments.length
|
||||
"
|
||||
class="flex flex-wrap gap-1 mt-1"
|
||||
>
|
||||
<Link
|
||||
v-for="seg in clientcase.contract_segments"
|
||||
:key="seg.id || seg.name || seg"
|
||||
:href="
|
||||
route('clientCase.show', {
|
||||
client_case: clientcase.case_uuid,
|
||||
}) +
|
||||
'?segment=' +
|
||||
(seg.id || seg)
|
||||
"
|
||||
class="group/seg text-[10px] uppercase tracking-wide bg-gradient-to-br from-purple-50 to-purple-100 text-purple-700 border border-purple-200 px-1.5 py-0.5 rounded hover:from-purple-100 hover:to-purple-200 hover:border-purple-300 transition"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
{{ seg.name || seg }}
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
clientcase.case_segments && clientcase.case_segments.length
|
||||
"
|
||||
class="flex flex-wrap gap-1 mt-1"
|
||||
>
|
||||
<Link
|
||||
v-for="seg in clientcase.case_segments"
|
||||
:key="seg.id || seg.name"
|
||||
:href="
|
||||
route('clientCase.show', {
|
||||
client_case: clientcase.case_uuid,
|
||||
}) +
|
||||
'?segment=' +
|
||||
(seg.id || seg)
|
||||
"
|
||||
class="text-[10px] uppercase tracking-wide bg-slate-100 text-slate-600 border border-slate-200 px-1.5 py-0.5 rounded hover:bg-slate-200 hover:text-slate-700 transition"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
{{ seg.name }}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="!result.clients.length && !result.client_cases.length"
|
||||
class="p-8 text-center text-sm text-slate-500"
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="shrink-0 w-6 h-6 flex items-center justify-center"
|
||||
>C</Badge
|
||||
>
|
||||
Ni rezultatov.
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-medium">{{ client.full_name }}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator v-if="result.clients.length && result.client_cases.length" />
|
||||
|
||||
<!-- Client Cases Results -->
|
||||
<div v-if="result.client_cases.length">
|
||||
<div
|
||||
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
|
||||
>
|
||||
<span>Primeri</span>
|
||||
<Badge variant="secondary">{{ result.client_cases.length }}</Badge>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Card
|
||||
v-for="clientcase in result.client_cases"
|
||||
:key="clientcase.case_uuid"
|
||||
class="hover:shadow-md transition p-0"
|
||||
>
|
||||
<CardContent class="p-3 space-y-2">
|
||||
<div class="space-y-1">
|
||||
<Link
|
||||
:href="
|
||||
route('clientCase.show', {
|
||||
client_case: clientcase.case_uuid,
|
||||
})
|
||||
"
|
||||
class="text-sm font-medium hover:underline block"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
{{ clientcase.full_name }}
|
||||
</Link>
|
||||
<div
|
||||
v-if="clientcase.client_full_name"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
Naročnik: {{ clientcase.client_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="clientcase.contract_reference"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Badge variant="outline" class="font-mono text-xs">
|
||||
{{ clientcase.contract_reference }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
clientcase.contract_segments && clientcase.contract_segments.length
|
||||
"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Link
|
||||
v-for="seg in clientcase.contract_segments"
|
||||
:key="seg.id || seg.name || seg"
|
||||
:href="
|
||||
route('clientCase.show', {
|
||||
client_case: clientcase.case_uuid,
|
||||
}) +
|
||||
'?segment=' +
|
||||
(seg.id || seg)
|
||||
"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<Badge variant="secondary" class="text-xs uppercase">
|
||||
{{ seg.name || seg }}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
clientcase.case_segments && clientcase.case_segments.length
|
||||
"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Link
|
||||
v-for="seg in clientcase.case_segments"
|
||||
:key="seg.id || seg.name"
|
||||
:href="
|
||||
route('clientCase.show', {
|
||||
client_case: clientcase.case_uuid,
|
||||
}) +
|
||||
'?segment=' +
|
||||
(seg.id || seg)
|
||||
"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<Badge variant="outline" class="text-xs uppercase">
|
||||
{{ seg.name }}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div
|
||||
v-if="!result.clients.length && !result.client_cases.length"
|
||||
class="p-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Ni rezultatov.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { usePage, Link, router } from "@inertiajs/vue3";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faBell } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BellIcon } from "lucide-vue-next";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
|
||||
const page = usePage();
|
||||
const due = computed(
|
||||
@@ -83,12 +83,8 @@ function markRead(item) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown
|
||||
align="right"
|
||||
width="72"
|
||||
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
|
||||
>
|
||||
<template #trigger>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="default" class="relative">
|
||||
<BellIcon />
|
||||
|
||||
@@ -100,32 +96,30 @@ function markRead(item) {
|
||||
{{ count }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</template>
|
||||
</PopoverTrigger>
|
||||
|
||||
<template #content>
|
||||
<div
|
||||
class="px-3 py-2 text-xs text-gray-400 border-b sticky top-0 bg-white z-10 flex items-center justify-between"
|
||||
>
|
||||
<span>Zapadejo danes</span>
|
||||
<PopoverContent align="end" class="w-96 p-0">
|
||||
<div class="px-4 py-3 flex items-center justify-between border-b">
|
||||
<span class="text-sm font-medium">Zapadejo danes</span>
|
||||
<Link
|
||||
:href="route('notifications.unread')"
|
||||
class="text-indigo-600 hover:text-indigo-700"
|
||||
class="text-sm text-primary hover:underline"
|
||||
>Vsa obvestila</Link
|
||||
>
|
||||
</div>
|
||||
<!-- Scrollable content area with max height -->
|
||||
<div class="max-h-80 overflow-auto">
|
||||
<div v-if="!count" class="px-3 py-3 text-sm text-gray-500">
|
||||
Ni zapadlih aktivnosti danes.
|
||||
|
||||
<ScrollArea class="h-72">
|
||||
<div v-if="!count" class="px-4 py-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">Ni zapadlih aktivnosti danes.</p>
|
||||
</div>
|
||||
<ul v-else class="divide-y">
|
||||
<li
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="px-3 py-2 text-sm flex items-start gap-2"
|
||||
class="px-4 py-3 flex items-start gap-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-800 truncate">
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<div class="font-medium truncate">
|
||||
<template v-if="item.contract?.uuid">
|
||||
Pogodba:
|
||||
<Link
|
||||
@@ -135,7 +129,7 @@ function markRead(item) {
|
||||
client_case: item.contract.client_case.uuid,
|
||||
})
|
||||
"
|
||||
class="text-indigo-600 hover:text-indigo-700 hover:underline"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ item.contract?.reference || "—" }}
|
||||
</Link>
|
||||
@@ -148,7 +142,7 @@ function markRead(item) {
|
||||
:href="
|
||||
route('clientCase.show', { client_case: item.client_case.uuid })
|
||||
"
|
||||
class="text-indigo-600 hover:text-indigo-700 hover:underline"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ item.client_case?.person?.full_name || "—" }}
|
||||
</Link>
|
||||
@@ -157,37 +151,38 @@ function markRead(item) {
|
||||
</div>
|
||||
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
|
||||
<div
|
||||
class="text-xs text-gray-500 truncate"
|
||||
class="text-xs text-muted-foreground truncate"
|
||||
v-if="item.contract?.client?.person?.full_name"
|
||||
>
|
||||
Partner: {{ item.contract.client.person.full_name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-500 truncate"
|
||||
class="text-xs text-muted-foreground truncate"
|
||||
v-else-if="item.client_case?.client?.person?.full_name"
|
||||
>
|
||||
Partner: {{ item.client_case.client.person.full_name }}
|
||||
</div>
|
||||
<div class="text-gray-600 truncate" v-if="item.contract">
|
||||
<div class="text-sm truncate" v-if="item.contract">
|
||||
{{ fmtEUR(item.contract?.account?.balance_amount) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="text-xs text-gray-500 whitespace-nowrap">
|
||||
<div class="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<div class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{{ fmtDate(item.due_date) }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-gray-400 hover:text-gray-600"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click.stop="markRead(item)"
|
||||
title="Skrij obvestilo"
|
||||
>
|
||||
Skrij
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
||||
import {
|
||||
PackageIcon,
|
||||
PhoneIcon,
|
||||
UsersIcon,
|
||||
SearchIcon,
|
||||
SaveIcon,
|
||||
ArrowLeftIcon,
|
||||
FilterIcon,
|
||||
CalendarIcon,
|
||||
CheckCircle2Icon,
|
||||
XCircleIcon,
|
||||
BadgeCheckIcon,
|
||||
} from "lucide-vue-next";
|
||||
import { fmtDateDMY } from "@/Utilities/functions";
|
||||
import { upperFirst } from "lodash";
|
||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import AppRangeDatePicker from "@/Components/app/ui/AppRangeDatePicker.vue";
|
||||
|
||||
const props = defineProps({
|
||||
profiles: { type: Array, default: () => [] },
|
||||
senders: { type: Array, default: () => [] },
|
||||
templates: { type: Array, default: () => [] },
|
||||
segments: { type: Array, default: () => [] },
|
||||
clients: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const creatingFromContracts = ref(false);
|
||||
|
||||
const createMode = ref("numbers"); // 'numbers' | 'contracts'
|
||||
const form = useForm({
|
||||
type: "sms",
|
||||
name: "",
|
||||
description: "",
|
||||
profile_id: null,
|
||||
sender_id: null,
|
||||
template_id: null,
|
||||
delivery_report: false,
|
||||
body: "",
|
||||
numbers: "", // one per line
|
||||
});
|
||||
|
||||
const filteredSenders = computed(() => {
|
||||
if (!form.profile_id) return props.senders;
|
||||
return props.senders.filter((s) => s.profile_id === form.profile_id);
|
||||
});
|
||||
|
||||
function onTemplateChange() {
|
||||
const template = props.templates.find((t) => t.id === form.template_id);
|
||||
if (template?.content) {
|
||||
form.body = template.content;
|
||||
} else {
|
||||
form.body = "";
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const lines = (form.numbers || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
if (!form.profile_id && !form.template_id) {
|
||||
alert("Izberi SMS profil ali predlogo.");
|
||||
return;
|
||||
}
|
||||
if (!form.template_id && !form.body) {
|
||||
alert("Vnesi vsebino sporočila ali izberi predlogo.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
items: lines.map((number) => ({
|
||||
number,
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
router.post(route("admin.packages.store"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("admin.packages.index"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Contracts mode state & actions
|
||||
const contracts = ref({
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
});
|
||||
const segmentId = ref(null);
|
||||
const search = ref("");
|
||||
const clientId = ref(null);
|
||||
const startDateRange = ref({ start: null, end: null });
|
||||
const promiseDateRange = ref({ start: null, end: null });
|
||||
const onlyMobile = ref(false);
|
||||
const onlyValidated = ref(false);
|
||||
const loadingContracts = ref(false);
|
||||
|
||||
// Transform clients for AppCombobox
|
||||
const clientItems = computed(() =>
|
||||
props.clients.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))
|
||||
);
|
||||
const selectedContractIds = ref(new Set());
|
||||
const perPage = ref(25);
|
||||
|
||||
// DataTable columns definition
|
||||
const contractColumns = [
|
||||
{ accessorKey: "reference", header: "Pogodba" },
|
||||
{
|
||||
id: "person",
|
||||
accessorFn: (row) => row.person?.full_name || "—",
|
||||
header: "Primer",
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
accessorFn: (row) => row.client?.name || "—",
|
||||
header: "Stranka",
|
||||
},
|
||||
{ accessorKey: "start_date", header: "Datum začetka" },
|
||||
{ accessorKey: "promise_date", header: "Zadnja obljuba" },
|
||||
{
|
||||
id: "selected_phone",
|
||||
accessorFn: (row) => row.selected_phone?.number || "—",
|
||||
header: "Izbrana številka",
|
||||
},
|
||||
{
|
||||
id: "segment",
|
||||
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
|
||||
header: "Segment",
|
||||
},
|
||||
{ accessorKey: "no_phone_reason", header: "Opomba" },
|
||||
];
|
||||
|
||||
function onSelectionChange(selectedKeys) {
|
||||
// selectedKeys are indices from the table
|
||||
const newSelection = new Set();
|
||||
selectedKeys.forEach((key) => {
|
||||
const index = parseInt(key);
|
||||
if (contracts.value.data[index]) {
|
||||
newSelection.add(contracts.value.data[index].id);
|
||||
}
|
||||
});
|
||||
selectedContractIds.value = newSelection;
|
||||
}
|
||||
|
||||
async function loadContracts(url = null) {
|
||||
loadingContracts.value = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateRange.value?.start)
|
||||
params.append("start_date_from", startDateRange.value.start);
|
||||
if (startDateRange.value?.end)
|
||||
params.append("start_date_to", startDateRange.value.end);
|
||||
if (promiseDateRange.value?.start)
|
||||
params.append("promise_date_from", promiseDateRange.value.start);
|
||||
if (promiseDateRange.value?.end)
|
||||
params.append("promise_date_to", promiseDateRange.value.end);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
|
||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const { data: json } = await axios.get(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
|
||||
// Wait for next tick before updating to avoid Vue reconciliation issues
|
||||
await nextTick();
|
||||
|
||||
contracts.value = {
|
||||
data: json.data || [],
|
||||
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
} finally {
|
||||
loadingContracts.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectContract(id) {
|
||||
const s = selectedContractIds.value;
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
s.add(id);
|
||||
}
|
||||
selectedContractIds.value = new Set(Array.from(s));
|
||||
}
|
||||
|
||||
// Get row selection state for DataTable
|
||||
const rowSelection = computed(() => {
|
||||
const selection = {};
|
||||
contracts.value.data.forEach((contract, index) => {
|
||||
if (selectedContractIds.value.has(contract.id)) {
|
||||
selection[index.toString()] = true;
|
||||
}
|
||||
});
|
||||
return selection;
|
||||
});
|
||||
|
||||
// Computed key to force DataTable re-render on page change
|
||||
const tableKey = computed(() => {
|
||||
return `contracts-${contracts.value.meta.current_page}-${contracts.value.data.length}`;
|
||||
});
|
||||
|
||||
function clearSelection() {
|
||||
selectedContractIds.value = new Set();
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
if (page < 1 || page > contracts.value.meta.last_page) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateRange.value?.start)
|
||||
params.append("start_date_from", startDateRange.value.start);
|
||||
if (startDateRange.value?.end) params.append("start_date_to", startDateRange.value.end);
|
||||
if (promiseDateRange.value?.start)
|
||||
params.append("promise_date_from", promiseDateRange.value.start);
|
||||
if (promiseDateRange.value?.end)
|
||||
params.append("promise_date_to", promiseDateRange.value.end);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
params.append("page", page);
|
||||
|
||||
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
loadContracts(url);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
segmentId.value = null;
|
||||
clientId.value = null;
|
||||
search.value = "";
|
||||
startDateRange.value = { start: null, end: null };
|
||||
promiseDateRange.value = { start: null, end: null };
|
||||
onlyMobile.value = false;
|
||||
onlyValidated.value = false;
|
||||
contracts.value = {
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function submitCreateFromContracts() {
|
||||
const ids = Array.from(selectedContractIds.value);
|
||||
if (!ids.length) return;
|
||||
|
||||
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
|
||||
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
|
||||
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
|
||||
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
contract_ids: ids,
|
||||
};
|
||||
|
||||
creatingFromContracts.value = true;
|
||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("admin.packages.index"));
|
||||
},
|
||||
onError: (errors) => {
|
||||
const first = errors && Object.values(errors)[0];
|
||||
if (first) {
|
||||
alert(String(first));
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
creatingFromContracts.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const numbersCount = computed(() => {
|
||||
return (form.numbers || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean).length;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Ustvari SMS paket">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<Link :href="route('admin.packages.index')">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<PackageIcon class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Ustvari SMS paket</h1>
|
||||
<p class="text-sm text-muted-foreground">Pošlji SMS sporočila v paketu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<Tabs v-model="createMode" class="w-full">
|
||||
<TabsList class="flex flex-row justify-baseline py-4">
|
||||
<TabsTrigger value="numbers" class="p-3">
|
||||
<span class="flex gap-2 items-center align-middle justify-center">
|
||||
<PhoneIcon class="h-5 w-5" />Vnos številk
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="contracts" class="p-3">
|
||||
<span class="flex gap-2 items-center align-middle justify-center">
|
||||
<UsersIcon class="h-5 w-5" />Iz pogodb (segment)
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Package Details Card -->
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Podatki o paketu</CardTitle>
|
||||
<CardDescription>Osnovne informacije in SMS nastavitve</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Ime paketa</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="Npr. SMS kampanja december 2024"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Opis</Label>
|
||||
<Input
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Neobvezen opis paketa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- SMS Configuration -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4">SMS nastavitve</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>SMS profil</Label>
|
||||
<Select v-model="form.profile_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi profil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">
|
||||
{{ p.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Pošiljatelj</Label>
|
||||
<Select v-model="form.sender_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi pošiljatelja" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
|
||||
{{ s.sname }}
|
||||
<span v-if="s.phone_number" class="text-muted-foreground">
|
||||
({{ s.phone_number }})
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi predlogo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="body">Vsebina sporočila</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
v-model="form.body"
|
||||
rows="4"
|
||||
placeholder="Vsebina SMS sporočila..."
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:model-value="form.delivery_report"
|
||||
@update:model-value="(val) => (form.delivery_report = val)"
|
||||
id="delivery-report"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Label for="delivery-report" class="cursor-pointer text-sm">
|
||||
Zahtevaj delivery report
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ form.body?.length || 0 }} znakov
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Numbers Mode -->
|
||||
<TabsContent value="numbers">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Telefonske številke</CardTitle>
|
||||
<CardDescription
|
||||
>Vnesi telefonske številke prejemnikov (ena na vrstico)</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Textarea
|
||||
v-model="form.numbers"
|
||||
rows="10"
|
||||
placeholder="+38640123456 +38640123457 +38641234567"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<strong>{{ numbersCount }}</strong>
|
||||
{{
|
||||
numbersCount === 1
|
||||
? "številka"
|
||||
: numbersCount < 5
|
||||
? "številke"
|
||||
: "številk"
|
||||
}}
|
||||
</p>
|
||||
<Badge v-if="numbersCount > 0" variant="secondary">
|
||||
<CheckCircle2Icon class="h-3 w-3 mr-1" />
|
||||
Pripravljeno
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button
|
||||
@click="submitCreate"
|
||||
:disabled="numbersCount === 0 || (!form.profile_id && !form.template_id)"
|
||||
>
|
||||
<SaveIcon class="h-4 w-4 mr-2" />
|
||||
Ustvari paket
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Contracts Mode -->
|
||||
<TabsContent value="contracts">
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Filtri za pogodbe</CardTitle>
|
||||
<CardDescription
|
||||
>Najdi prejemnike glede na pogodbe in segmente</CardDescription
|
||||
>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs">
|
||||
<FilterIcon class="h-3 w-3 mr-1" />
|
||||
Napredno iskanje
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Basic filters -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>Segment</Label>
|
||||
<Select v-model="segmentId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vsi segmenti" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Stranka</Label>
|
||||
<AppCombobox
|
||||
v-model="clientId"
|
||||
:items="clientItems"
|
||||
placeholder="Vse stranke"
|
||||
search-placeholder="Išči stranko..."
|
||||
empty-text="Stranka ni najdena."
|
||||
button-class="w-full"
|
||||
@update:model-value="loadContracts()"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Iskanje po referenci</Label>
|
||||
<Input
|
||||
v-model="search"
|
||||
@keyup.enter="loadContracts()"
|
||||
placeholder="Vnesi referenco..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Date filters -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<CalendarIcon class="h-4 w-4" />
|
||||
Datumski filtri
|
||||
</h4>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
|
||||
<AppRangeDatePicker
|
||||
v-model="startDateRange"
|
||||
placeholder="Izberi obdobje"
|
||||
button-class="w-full"
|
||||
:number-of-months="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
|
||||
<AppRangeDatePicker
|
||||
v-model="promiseDateRange"
|
||||
placeholder="Izberi obdobje"
|
||||
button-class="w-full"
|
||||
:number-of-months="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Phone filters -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:model-value="onlyMobile"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
onlyMobile = val;
|
||||
}
|
||||
"
|
||||
id="only-mobile"
|
||||
/>
|
||||
<Label for="only-mobile" class="cursor-pointer text-sm">
|
||||
Samo mobilne številke
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:model-value="onlyValidated"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
onlyValidated = val;
|
||||
}
|
||||
"
|
||||
id="only-validated"
|
||||
/>
|
||||
<Label for="only-validated" class="cursor-pointer text-sm">
|
||||
Samo potrjene številke
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="loadContracts()">
|
||||
<SearchIcon class="h-4 w-4" />
|
||||
Išči pogodbe
|
||||
</Button>
|
||||
<Button @click="resetFilters" variant="outline">
|
||||
<XCircleIcon class="h-4 w-4" />
|
||||
Počisti filtre
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Results -->
|
||||
<Card v-if="contracts.data.length > 0 || loadingContracts">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
|
||||
<CardDescription v-if="contracts.meta.total > 0">
|
||||
Najdeno {{ contracts.meta.total }}
|
||||
{{
|
||||
contracts.meta.total === 1
|
||||
? "pogodba"
|
||||
: contracts.meta.total < 5
|
||||
? "pogodbe"
|
||||
: "pogodb"
|
||||
}}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
<div class="flex justify-end gap-2" v-if="selectedContractIds.size > 0">
|
||||
<Badge
|
||||
v-if="selectedContractIds.size > 0"
|
||||
variant="secondary"
|
||||
class="text-sm"
|
||||
>
|
||||
<CheckCircle2Icon class="h-3 w-3" />
|
||||
Izbrano: {{ selectedContractIds.size }}
|
||||
</Badge>
|
||||
<Button
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button
|
||||
@click="submitCreateFromContracts"
|
||||
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
||||
>
|
||||
<SaveIcon class="h-4 w-4" />
|
||||
Ustvari paket ({{ selectedContractIds.size }}
|
||||
{{
|
||||
selectedContractIds.size === 1
|
||||
? "pogodba"
|
||||
: selectedContractIds.size < 5
|
||||
? "pogodbe"
|
||||
: "pogodb"
|
||||
}})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<DataTableNew2
|
||||
v-if="!loadingContracts"
|
||||
:key="tableKey"
|
||||
:columns="contractColumns"
|
||||
:data="contracts.data"
|
||||
:enableRowSelection="true"
|
||||
:rowSelection="rowSelection"
|
||||
:showPagination="true"
|
||||
:page-size="50"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:showToolbar="false"
|
||||
@selection:change="onSelectionChange"
|
||||
>
|
||||
<template #cell-reference="{ row }">
|
||||
<div v-if="row.original" class="space-y-1">
|
||||
<p class="font-medium">{{ row.original.reference || "—" }}</p>
|
||||
<p class="text-xs text-muted-foreground font-mono">
|
||||
#{{ row.original.id }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-person="{ row }">
|
||||
<span v-if="row.original" class="text-xs">{{
|
||||
row.original.person?.full_name || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-client="{ row }">
|
||||
<span v-if="row.original" class="text-xs">{{
|
||||
row.original.client?.name || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-start_date="{ row }">
|
||||
{{ fmtDateDMY(row.start_date) || "—" }}
|
||||
</template>
|
||||
|
||||
<template #cell-promise_date="{ row }">
|
||||
{{ fmtDateDMY(row.promise_date) || "—" }}
|
||||
</template>
|
||||
|
||||
<template #cell-selected_phone="{ row }">
|
||||
<div v-if="row.selected_phone" class="space-y-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span>{{ row.selected_phone.number }}</span>
|
||||
<span
|
||||
><Badge
|
||||
v-if="row.selected_phone.validated"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
<BadgeCheckIcon />
|
||||
Potrjena
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="destructive"
|
||||
class="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums text-accent"
|
||||
>
|
||||
Nepotrjena
|
||||
</Badge></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
|
||||
</template>
|
||||
|
||||
<template #cell-no_phone_reason="{ row }">
|
||||
<span v-if="row.original" class="text-xs text-muted-foreground">{{
|
||||
row.original.no_phone_reason || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
|
||||
<div v-else class="text-center text-muted-foreground py-24">Nalaganje...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -1,57 +1,35 @@
|
||||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import {
|
||||
PackageIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
EyeIcon,
|
||||
} from "lucide-vue-next";
|
||||
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { fmtDateTime } from "@/Utilities/functions";
|
||||
|
||||
const props = defineProps({
|
||||
packages: { type: Object, required: true },
|
||||
profiles: { type: Array, default: () => [] },
|
||||
senders: { type: Array, default: () => [] },
|
||||
templates: { type: Array, default: () => [] },
|
||||
segments: { type: Array, default: () => [] },
|
||||
clients: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const deletingId = ref(null);
|
||||
const creatingFromContracts = ref(false);
|
||||
const packageToDelete = ref(null);
|
||||
const showDeleteDialog = ref(false);
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: "id", header: "ID" },
|
||||
{ accessorKey: "uuid", header: "UUID" },
|
||||
{ accessorKey: "name", header: "Ime" },
|
||||
{ accessorKey: "type", header: "Tip" },
|
||||
{ accessorKey: "status", header: "Status" },
|
||||
@@ -73,242 +51,23 @@ function goShow(id) {
|
||||
router.visit(route("admin.packages.show", id));
|
||||
}
|
||||
|
||||
const showCreate = ref(false);
|
||||
const createMode = ref("numbers"); // 'numbers' | 'contracts'
|
||||
const form = useForm({
|
||||
type: "sms",
|
||||
name: "",
|
||||
description: "",
|
||||
profile_id: null,
|
||||
sender_id: null,
|
||||
template_id: null,
|
||||
delivery_report: false,
|
||||
body: "",
|
||||
numbers: "", // one per line
|
||||
});
|
||||
|
||||
const filteredSenders = computed(() => {
|
||||
if (!form.profile_id) return props.senders;
|
||||
return props.senders.filter((s) => s.profile_id === form.profile_id);
|
||||
});
|
||||
|
||||
function onTemplateChange() {
|
||||
const template = props.templates.find((t) => t.id === form.template_id);
|
||||
if (template?.content) {
|
||||
form.body = template.content;
|
||||
} else {
|
||||
form.body = "";
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const lines = (form.numbers || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
if (!form.profile_id && !form.template_id) {
|
||||
// require profile if no template/default profile resolution available
|
||||
alert("Izberi SMS profil ali predlogo.");
|
||||
return;
|
||||
}
|
||||
if (!form.template_id && !form.body) {
|
||||
alert("Vnesi vsebino sporočila ali izberi predlogo.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
items: lines.map((number) => ({
|
||||
number,
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
router.post(route("admin.packages.store"), payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
showCreate.value = false;
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Contracts mode state & actions
|
||||
const contracts = ref({
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
});
|
||||
const segmentId = ref(null);
|
||||
const search = ref("");
|
||||
const clientId = ref(null);
|
||||
const startDateFrom = ref("");
|
||||
const startDateTo = ref("");
|
||||
const promiseDateFrom = ref("");
|
||||
const promiseDateTo = ref("");
|
||||
const onlyMobile = ref(false);
|
||||
const onlyValidated = ref(false);
|
||||
const loadingContracts = ref(false);
|
||||
const selectedContractIds = ref(new Set());
|
||||
const perPage = ref(25);
|
||||
|
||||
async function loadContracts(url = null) {
|
||||
loadingContracts.value = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
|
||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const res = await fetch(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
const json = await res.json();
|
||||
contracts.value = {
|
||||
data: json.data || [],
|
||||
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
} finally {
|
||||
loadingContracts.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectContract(id) {
|
||||
const s = selectedContractIds.value;
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
s.add(id);
|
||||
}
|
||||
// force reactivity
|
||||
selectedContractIds.value = new Set(Array.from(s));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedContractIds.value = new Set();
|
||||
}
|
||||
|
||||
function deletePackage(pkg) {
|
||||
function openDeleteDialog(pkg) {
|
||||
if (!pkg || pkg.status !== "draft") return;
|
||||
if (!confirm(`Izbrišem paket #${pkg.id}?`)) return;
|
||||
deletingId.value = pkg.id;
|
||||
router.delete(route("admin.packages.destroy", pkg.id), {
|
||||
packageToDelete.value = pkg;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!packageToDelete.value) return;
|
||||
deletingId.value = packageToDelete.value.id;
|
||||
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||
onSuccess: () => {
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
onFinish: () => {
|
||||
deletingId.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const currentPageIds = contracts.value.data.map((c) => c.id);
|
||||
const allSelected = currentPageIds.every((id) => selectedContractIds.value.has(id));
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all on current page
|
||||
currentPageIds.forEach((id) => selectedContractIds.value.delete(id));
|
||||
} else {
|
||||
// Select all on current page
|
||||
currentPageIds.forEach((id) => selectedContractIds.value.add(id));
|
||||
}
|
||||
|
||||
// Force reactivity
|
||||
selectedContractIds.value = new Set(Array.from(selectedContractIds.value));
|
||||
}
|
||||
|
||||
const allCurrentPageSelected = computed(() => {
|
||||
if (!contracts.value.data.length) return false;
|
||||
return contracts.value.data.every((c) => selectedContractIds.value.has(c.id));
|
||||
});
|
||||
|
||||
const someCurrentPageSelected = computed(() => {
|
||||
if (!contracts.value.data.length) return false;
|
||||
return (
|
||||
contracts.value.data.some((c) => selectedContractIds.value.has(c.id)) &&
|
||||
!allCurrentPageSelected.value
|
||||
);
|
||||
});
|
||||
|
||||
function goContractsPage(delta) {
|
||||
const { current_page } = contracts.value.meta;
|
||||
const nextPage = current_page + delta;
|
||||
if (nextPage < 1 || nextPage > contracts.value.meta.last_page) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
params.append("page", nextPage);
|
||||
|
||||
const base = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
loadContracts(base);
|
||||
}
|
||||
|
||||
function submitCreateFromContracts() {
|
||||
const ids = Array.from(selectedContractIds.value);
|
||||
if (!ids.length) return;
|
||||
// Optional quick client-side sanity: if all selected are from current page and none have phones, warn early.
|
||||
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
|
||||
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
|
||||
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
|
||||
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
contract_ids: ids,
|
||||
};
|
||||
|
||||
creatingFromContracts.value = true;
|
||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||
onSuccess: () => {
|
||||
clearSelection();
|
||||
showCreate.value = false;
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Show the first validation error if present
|
||||
const first = errors && Object.values(errors)[0];
|
||||
if (first) {
|
||||
alert(String(first));
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
creatingFromContracts.value = false;
|
||||
showDeleteDialog.value = false;
|
||||
packageToDelete.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -323,444 +82,16 @@ function submitCreateFromContracts() {
|
||||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>SMS paketi</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
@click="showCreate = !showCreate"
|
||||
:variant="showCreate ? 'outline' : 'default'"
|
||||
>
|
||||
<component :is="showCreate ? XIcon : PlusIcon" class="h-4 w-4 mr-2" />
|
||||
{{ showCreate ? "Zapri" : "Nov paket" }}
|
||||
</Button>
|
||||
<Link :href="route('admin.packages.create')">
|
||||
<Button>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
Nov paket
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card v-if="showCreate" class="mb-6">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<Label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="numbers"
|
||||
v-model="createMode"
|
||||
class="rounded-full"
|
||||
/>
|
||||
Vnos številk
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="contracts"
|
||||
v-model="createMode"
|
||||
class="rounded-full"
|
||||
/>
|
||||
Iz pogodb (segment)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Profil</Label>
|
||||
<Select v-model="form.profile_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">{{
|
||||
p.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Pošiljatelj</Label>
|
||||
<Select v-model="form.sender_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
|
||||
{{ s.sname }} <span v-if="s.phone_number">({{ s.phone_number }})</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">{{
|
||||
t.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<Label>Vsebina (če ni predloge)</Label>
|
||||
<Textarea v-model="form.body" rows="3" placeholder="Sporočilo..." />
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="form.delivery_report"
|
||||
@update:checked="(val) => (form.delivery_report = val)"
|
||||
id="delivery-report"
|
||||
/>
|
||||
<Label for="delivery-report" class="cursor-pointer"
|
||||
>Zahtevaj delivery report</Label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Numbers mode -->
|
||||
<template v-if="createMode === 'numbers'">
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<Label>Telefonske številke (ena na vrstico)</Label>
|
||||
<Textarea
|
||||
v-model="form.numbers"
|
||||
rows="4"
|
||||
placeholder="+38640123456 +38640123457"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-3 flex items-center justify-end gap-2">
|
||||
<Button @click="submitCreate"> Ustvari paket </Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Contracts mode -->
|
||||
<template v-else>
|
||||
<div class="sm:col-span-3 space-y-4">
|
||||
<Separator />
|
||||
<!-- Basic filters -->
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Segment</Label>
|
||||
<Select v-model="segmentId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vsi segmenti" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">{{
|
||||
s.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Stranka</Label>
|
||||
<Select v-model="clientId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vse stranke" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vse stranke</SelectItem>
|
||||
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">{{
|
||||
c.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Iskanje po referenci</Label>
|
||||
<Input
|
||||
v-model="search"
|
||||
@keyup.enter="loadContracts()"
|
||||
type="text"
|
||||
placeholder="Vnesi referenco..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date range filters -->
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Datumski filtri</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
||||
Datum začetka pogodbe
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Od</Label>
|
||||
<Input
|
||||
v-model="startDateFrom"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Do</Label>
|
||||
<Input
|
||||
v-model="startDateTo"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
||||
Datum obljube plačila
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Od</Label>
|
||||
<Input
|
||||
v-model="promiseDateFrom"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Do</Label>
|
||||
<Input
|
||||
v-model="promiseDateTo"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone filters -->
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyMobile"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
onlyMobile = val;
|
||||
loadContracts();
|
||||
}
|
||||
"
|
||||
id="only-mobile"
|
||||
/>
|
||||
<Label for="only-mobile" class="cursor-pointer"
|
||||
>Samo mobilne številke</Label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyValidated"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
onlyValidated = val;
|
||||
loadContracts();
|
||||
}
|
||||
"
|
||||
id="only-validated"
|
||||
/>
|
||||
<Label for="only-validated" class="cursor-pointer"
|
||||
>Samo potrjene številke</Label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="loadContracts()">
|
||||
<SearchIcon class="h-4 w-4 mr-2" />
|
||||
Išči pogodbe
|
||||
</Button>
|
||||
<Button
|
||||
@click="
|
||||
segmentId = null;
|
||||
clientId = null;
|
||||
search = '';
|
||||
startDateFrom = '';
|
||||
startDateTo = '';
|
||||
promiseDateFrom = '';
|
||||
promiseDateTo = '';
|
||||
onlyMobile = false;
|
||||
onlyValidated = false;
|
||||
contracts.value = {
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
"
|
||||
variant="outline"
|
||||
>
|
||||
Počisti filtre
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results table -->
|
||||
<div class="sm:col-span-3">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allCurrentPageSelected"
|
||||
:indeterminate="someCurrentPageSelected"
|
||||
@change="toggleSelectAll"
|
||||
:disabled="!contracts.data.length"
|
||||
class="rounded"
|
||||
title="Izberi vse na tej strani"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Pogodba</TableHead>
|
||||
<TableHead>Primer</TableHead>
|
||||
<TableHead>Stranka</TableHead>
|
||||
<TableHead>Datum začetka</TableHead>
|
||||
<TableHead>Zadnja obljuba</TableHead>
|
||||
<TableHead>Izbrana številka</TableHead>
|
||||
<TableHead>Opomba</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody v-if="!loadingContracts">
|
||||
<TableRow v-for="c in contracts.data" :key="c.id">
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedContractIds.has(c.id)"
|
||||
@change="toggleSelectContract(c.id)"
|
||||
class="rounded"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="font-mono text-xs text-muted-foreground">
|
||||
{{ c.uuid }}
|
||||
</div>
|
||||
<a
|
||||
v-if="c.case?.uuid"
|
||||
:href="route('clientCase.show', c.case.uuid)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{{ c.reference }}
|
||||
</a>
|
||||
<div v-else class="text-xs font-medium">{{ c.reference }}</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs">
|
||||
{{ c.person?.full_name || "—" }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs">{{ c.client?.name || "—" }}</TableCell>
|
||||
<TableCell class="text-xs">{{
|
||||
c.start_date
|
||||
? new Date(c.start_date).toLocaleDateString("sl-SI")
|
||||
: "—"
|
||||
}}</TableCell>
|
||||
<TableCell class="text-xs">{{
|
||||
c.promise_date
|
||||
? new Date(c.promise_date).toLocaleDateString("sl-SI")
|
||||
: "—"
|
||||
}}</TableCell>
|
||||
<TableCell>
|
||||
<div v-if="c.selected_phone" class="text-xs">
|
||||
{{ c.selected_phone.number }}
|
||||
<Badge
|
||||
v-if="c.selected_phone.is_mobile"
|
||||
variant="secondary"
|
||||
class="ml-1"
|
||||
>mobitel</Badge
|
||||
>
|
||||
<Badge
|
||||
v-if="c.selected_phone.is_validated"
|
||||
variant="default"
|
||||
class="ml-1"
|
||||
>potrjen</Badge
|
||||
>
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted-foreground">—</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
{{ c.no_phone_reason || "—" }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!contracts.data?.length">
|
||||
<TableCell colspan="8" class="text-center text-muted-foreground h-24">
|
||||
Ni rezultatov. Kliknite "Išči pogodbe" za prikaz.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
<TableBody v-else>
|
||||
<TableRow
|
||||
><TableCell colspan="8" class="text-center text-muted-foreground h-24"
|
||||
>Nalaganje...</TableCell
|
||||
></TableRow
|
||||
>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground flex items-center gap-4">
|
||||
<span v-if="contracts.data.length">
|
||||
Prikazano stran {{ contracts.meta.current_page }} od
|
||||
{{ contracts.meta.last_page }} (skupaj {{ contracts.meta.total }})
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-xs">Na stran:</Label>
|
||||
<Select v-model="perPage" @update:model-value="loadContracts()">
|
||||
<SelectTrigger class="w-20 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="10">10</SelectItem>
|
||||
<SelectItem :value="25">25</SelectItem>
|
||||
<SelectItem :value="50">50</SelectItem>
|
||||
<SelectItem :value="100">100</SelectItem>
|
||||
<SelectItem :value="200">200</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click="goContractsPage(-1)"
|
||||
:disabled="contracts.meta.current_page <= 1"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>Nazaj</Button
|
||||
>
|
||||
<Button
|
||||
@click="goContractsPage(1)"
|
||||
:disabled="contracts.meta.current_page >= contracts.meta.last_page"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>Naprej</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="sm:col-span-3" />
|
||||
<div class="sm:col-span-3 flex items-center justify-between gap-2">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">Izbrano: {{ selectedContractIds.size }}</span>
|
||||
<span v-if="selectedContractIds.size > 0" class="ml-2 text-muted-foreground"
|
||||
>({{
|
||||
selectedContractIds.size === 1
|
||||
? "1 pogodba"
|
||||
: `${selectedContractIds.size} pogodb`
|
||||
}})</span
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
@click="submitCreateFromContracts"
|
||||
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
||||
>Ustvari paket</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
@@ -771,7 +102,7 @@ function submitCreateFromContracts() {
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<PackageIcon size="18" />
|
||||
<CardTitle class="uppercase">Uvozi</CardTitle>
|
||||
<CardTitle class="uppercase">Paketi</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<DataTableNew2
|
||||
@@ -780,10 +111,6 @@ function submitCreateFromContracts() {
|
||||
:meta="packages"
|
||||
route-name="admin.packages.index"
|
||||
>
|
||||
<template #cell-uuid="{ row }">
|
||||
<span class="font-mono text-xs text-muted-foreground">{{ row.uuid }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
||||
</template>
|
||||
@@ -797,7 +124,9 @@ function submitCreateFromContracts() {
|
||||
</template>
|
||||
|
||||
<template #cell-finished_at="{ row }">
|
||||
<span class="text-xs text-muted-foreground">{{ row.finished_at ?? "—" }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
fmtDateTime(row.finished_at) ?? "—"
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
@@ -807,7 +136,7 @@ function submitCreateFromContracts() {
|
||||
</Button>
|
||||
<Button
|
||||
v-if="row.status === 'draft'"
|
||||
@click="deletePackage(row)"
|
||||
@click="openDeleteDialog(row)"
|
||||
:disabled="deletingId === row.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -818,5 +147,30 @@ function submitCreateFromContracts() {
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</AppCard>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Ali ste prepričani, da želite izbrisati paket
|
||||
<strong v-if="packageToDelete"
|
||||
>#{{ packageToDelete.id }} -
|
||||
{{ packageToDelete.name || "Brez imena" }}</strong
|
||||
>? Tega dejanja ni mogoče razveljaviti.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Prekliči</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@click="confirmDelete"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Izbriši
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -53,7 +54,7 @@ const form = useInertiaForm({
|
||||
props.actions[0].decisions.length > 0
|
||||
? props.actions[0].decisions[0].id
|
||||
: null,
|
||||
contract_uuid: props.contractUuid,
|
||||
contract_uuids: props.contractUuid ? [props.contractUuid] : [],
|
||||
send_auto_mail: true,
|
||||
attach_documents: false,
|
||||
attachment_document_ids: [],
|
||||
@@ -95,7 +96,7 @@ watch(
|
||||
watch(
|
||||
() => props.contractUuid,
|
||||
(cu) => {
|
||||
form.contract_uuid = cu || null;
|
||||
form.contract_uuids = cu ? [cu] : [];
|
||||
}
|
||||
);
|
||||
|
||||
@@ -103,7 +104,7 @@ watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
form.contract_uuid = props.contractUuid || null;
|
||||
form.contract_uuids = props.contractUuid ? [props.contractUuid] : [];
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -119,20 +120,29 @@ const store = async () => {
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const contractUuids =
|
||||
Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
|
||||
? form.contract_uuids
|
||||
: null;
|
||||
|
||||
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
||||
|
||||
form
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
phone_view: props.phoneMode,
|
||||
due_date: formatDateForSubmit(data.due_date),
|
||||
contract_uuids: contractUuids,
|
||||
create_for_all_contracts: isMultipleContracts,
|
||||
attachment_document_ids:
|
||||
templateAllowsAttachments.value && data.attach_documents
|
||||
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||
? data.attachment_document_ids
|
||||
: [],
|
||||
}))
|
||||
.post(route("clientCase.activity.store", props.client_case), {
|
||||
onSuccess: () => {
|
||||
close();
|
||||
form.reset("due_date", "amount", "note");
|
||||
form.reset("due_date", "amount", "note", "contract_uuids");
|
||||
emit("saved");
|
||||
},
|
||||
});
|
||||
@@ -165,13 +175,45 @@ const autoMailRequiresContract = computed(() => {
|
||||
return types.includes("contract");
|
||||
});
|
||||
|
||||
const autoMailDisabled = computed(() => {
|
||||
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
||||
const contractItems = computed(() => {
|
||||
return pageContracts.value.map((c) => ({
|
||||
value: c.uuid,
|
||||
label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`,
|
||||
}));
|
||||
});
|
||||
|
||||
const autoMailDisabled = computed(() => {
|
||||
if (!showSendAutoMail()) return false;
|
||||
|
||||
// Disable if multiple contracts selected
|
||||
if (form.contract_uuids && form.contract_uuids.length > 1) return true;
|
||||
|
||||
// Disable if template requires contract but none selected
|
||||
if (
|
||||
autoMailRequiresContract.value &&
|
||||
(!form.contract_uuids || form.contract_uuids.length === 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const autoMailDisabledHint = computed(() => {
|
||||
return autoMailDisabled.value
|
||||
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
|
||||
: "";
|
||||
if (!showSendAutoMail()) return "";
|
||||
|
||||
if (form.contract_uuids && form.contract_uuids.length > 1) {
|
||||
return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
|
||||
}
|
||||
|
||||
if (
|
||||
autoMailRequiresContract.value &&
|
||||
(!form.contract_uuids || form.contract_uuids.length === 0)
|
||||
) {
|
||||
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
watch(
|
||||
() => autoMailDisabled.value,
|
||||
@@ -231,9 +273,12 @@ const docsSource = computed(() => {
|
||||
});
|
||||
|
||||
const availableContractDocs = computed(() => {
|
||||
if (!form.contract_uuid) return [];
|
||||
if (!form.contract_uuids || form.contract_uuids.length === 0) return [];
|
||||
// Only show docs if exactly one contract is selected
|
||||
if (form.contract_uuids.length > 1) return [];
|
||||
const selectedUuid = form.contract_uuids[0];
|
||||
const docs = docsSource.value;
|
||||
const all = docs.filter((d) => d.contract_uuid === form.contract_uuid);
|
||||
const all = docs.filter((d) => d.contract_uuid === selectedUuid);
|
||||
if (!props.phoneMode) return all;
|
||||
return all.filter((d) => {
|
||||
const mime = (d.mime_type || "").toLowerCase();
|
||||
@@ -264,14 +309,14 @@ watch(
|
||||
[
|
||||
() => props.phoneMode,
|
||||
() => templateAllowsAttachments.value,
|
||||
() => form.contract_uuid,
|
||||
() => form.contract_uuids,
|
||||
() => form.decision_id,
|
||||
() => availableContractDocs.value.length,
|
||||
],
|
||||
() => {
|
||||
if (!props.phoneMode) return;
|
||||
if (!templateAllowsAttachments.value) return;
|
||||
if (!form.contract_uuid) return;
|
||||
if (!form.contract_uuids || form.contract_uuids.length !== 1) return;
|
||||
const docs = availableContractDocs.value;
|
||||
if (docs.length === 0) return;
|
||||
form.attach_documents = true;
|
||||
@@ -295,117 +340,148 @@ watch(
|
||||
@confirm="store"
|
||||
>
|
||||
<form @submit.prevent="store">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Akcija</Label>
|
||||
<Select v-model="form.action_id" :disabled="!actions || !actions.length">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi akcijo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Akcija</Label>
|
||||
<Select v-model="form.action_id" :disabled="!actions || !actions.length">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi akcijo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Odločitev</Label>
|
||||
<Select v-model="form.decision_id" :disabled="!decisions || !decisions.length">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi odločitev" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="d in decisions" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Odločitev</Label>
|
||||
<Select v-model="form.decision_id" :disabled="!decisions || !decisions.length">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi odločitev" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="d in decisions" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="activityNote">Opomba</Label>
|
||||
<Textarea
|
||||
id="activityNote"
|
||||
v-model="form.note"
|
||||
class="block w-full"
|
||||
placeholder="Opomba"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Pogodbe</Label>
|
||||
<AppMultiSelect
|
||||
v-model="form.contract_uuids"
|
||||
:items="contractItems"
|
||||
placeholder="Izberi pogodbe (neobvezno)"
|
||||
search-placeholder="Išči pogodbo..."
|
||||
empty-text="Ni pogodb."
|
||||
:clearable="true"
|
||||
:show-selected-chips="true"
|
||||
/>
|
||||
<p
|
||||
v-if="form.contract_uuids && form.contract_uuids.length > 1"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako
|
||||
pogodbo).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="activityDueDate">Datum zapadlosti</Label>
|
||||
<DatePicker
|
||||
id="activityDueDate"
|
||||
v-model="form.due_date"
|
||||
format="dd.MM.yyyy"
|
||||
:error="form.errors.due_date"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="activityNote">Opomba</Label>
|
||||
<Textarea
|
||||
id="activityNote"
|
||||
v-model="form.note"
|
||||
class="block w-full max-h-72"
|
||||
placeholder="Opomba"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="activityAmount">Znesek</Label>
|
||||
<CurrencyInput
|
||||
id="activityAmount"
|
||||
v-model="form.amount"
|
||||
:precision="{ min: 0, max: 4 }"
|
||||
placeholder="0,00"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="activityDueDate">Datum zapadlosti</Label>
|
||||
<DatePicker
|
||||
id="activityDueDate"
|
||||
v-model="form.due_date"
|
||||
format="dd.MM.yyyy"
|
||||
:error="form.errors.due_date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showSendAutoMail()" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch
|
||||
v-model="form.send_auto_mail"
|
||||
:disabled="autoMailDisabled"
|
||||
/>
|
||||
<Label class="cursor-pointer">Send auto email</Label>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="activityAmount">Znesek</Label>
|
||||
<CurrencyInput
|
||||
id="activityAmount"
|
||||
v-model="form.amount"
|
||||
:precision="{ min: 0, max: 4 }"
|
||||
placeholder="0,00"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showSendAutoMail()" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
|
||||
<Label class="cursor-pointer">Send auto email</Label>
|
||||
</div>
|
||||
<p v-if="autoMailDisabled" class="text-xs text-amber-600">
|
||||
{{ autoMailDisabledHint }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="autoMailDisabled" class="text-xs text-amber-600">
|
||||
{{ autoMailDisabledHint }}
|
||||
</p>
|
||||
|
||||
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<Switch v-model="form.attach_documents" />
|
||||
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="form.attach_documents"
|
||||
class="mt-2 border rounded p-2 max-h-48 overflow-auto"
|
||||
>
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
Izberite dokumente, ki bodo poslani kot priponke:
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
||||
<div v-if="c.uuid === form.contract_uuid">
|
||||
<div class="font-medium text-sm text-gray-700 mb-1">
|
||||
Pogodba {{ c.reference }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="doc in availableContractDocs"
|
||||
:key="doc.uuid || doc.id"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Switch
|
||||
:model-value="form.attachment_document_ids.includes(doc.id)"
|
||||
@update:model-value="(checked) => {
|
||||
<div
|
||||
v-if="
|
||||
templateAllowsAttachments &&
|
||||
form.contract_uuids &&
|
||||
form.contract_uuids.length === 1
|
||||
"
|
||||
class="mt-3"
|
||||
>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<Switch v-model="form.attach_documents" />
|
||||
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="form.attach_documents"
|
||||
class="mt-2 border rounded p-2 max-h-48 overflow-auto"
|
||||
>
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
Izberite dokumente, ki bodo poslani kot priponke:
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
||||
<div v-if="c.uuid === form.contract_uuids[0]">
|
||||
<div class="font-medium text-sm text-gray-700 mb-1">
|
||||
Pogodba {{ c.reference }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="doc in availableContractDocs"
|
||||
:key="doc.uuid || doc.id"
|
||||
class="flex items-center max-w-sm gap-2 text-sm"
|
||||
>
|
||||
<Switch
|
||||
:model-value="form.attachment_document_ids.includes(doc.id)"
|
||||
@update:model-value="
|
||||
(checked) => {
|
||||
if (checked) {
|
||||
if (!form.attachment_document_ids.includes(doc.id)) {
|
||||
form.attachment_document_ids.push(doc.id);
|
||||
}
|
||||
} else {
|
||||
form.attachment_document_ids = form.attachment_document_ids.filter(id => id !== doc.id);
|
||||
form.attachment_document_ids = form.attachment_document_ids.filter(
|
||||
(id) => id !== doc.id
|
||||
);
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<span>{{ doc.original_name || doc.name }}</span>
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="wrap-anywhere">
|
||||
<p>
|
||||
{{ doc.original_name || doc.name }}
|
||||
</p>
|
||||
<span class="text-xs text-gray-400"
|
||||
>({{ doc.extension?.toUpperCase() || "" }},
|
||||
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
|
||||
@@ -413,22 +489,23 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="availableContractDocs.length === 0"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
Ni dokumentov, povezanih s to pogodbo.
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="availableContractDocs.length === 0"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
Ni dokumentov, povezanih s to pogodbo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
|
||||
Shranjuje.
|
||||
</ActionMessage>
|
||||
</div>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
|
||||
<ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
|
||||
Shranjuje.
|
||||
</ActionMessage>
|
||||
</div>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Plus, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
client_case: { type: Object, required: true },
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const processing = ref(false);
|
||||
const metaEntries = ref([]);
|
||||
|
||||
// Extract meta entries from contract
|
||||
function extractMetaEntries(contract) {
|
||||
if (!contract?.meta) return [];
|
||||
|
||||
const results = [];
|
||||
const visit = (node, keyName) => {
|
||||
if (node === null || node === undefined) return;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((el) => visit(el));
|
||||
return;
|
||||
}
|
||||
if (typeof node === "object") {
|
||||
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
|
||||
if (hasValue || hasTitle) {
|
||||
const title = (node.title || keyName || "").toString().trim() || keyName || "";
|
||||
results.push({
|
||||
title,
|
||||
value: node.value ?? "",
|
||||
type: node.type || "string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
visit(v, k);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyName) {
|
||||
results.push({ title: keyName, value: node ?? "", type: "string" });
|
||||
}
|
||||
};
|
||||
visit(contract.meta, undefined);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Initialize meta entries when dialog opens
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal && props.contract) {
|
||||
const entries = extractMetaEntries(props.contract);
|
||||
metaEntries.value =
|
||||
entries.length > 0 ? entries : [{ title: "", value: "", type: "string" }];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function addEntry() {
|
||||
metaEntries.value.push({ title: "", value: "", type: "string" });
|
||||
}
|
||||
|
||||
function removeEntry(index) {
|
||||
metaEntries.value.splice(index, 1);
|
||||
if (metaEntries.value.length === 0) {
|
||||
metaEntries.value.push({ title: "", value: "", type: "string" });
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!props.contract?.uuid || processing.value) return;
|
||||
|
||||
// Filter out empty entries and build meta object
|
||||
const validEntries = metaEntries.value.filter((e) => e.title && e.title.trim() !== "");
|
||||
|
||||
const meta = {};
|
||||
validEntries.forEach((entry) => {
|
||||
meta[entry.title] = {
|
||||
title: entry.title,
|
||||
value: entry.value,
|
||||
type: entry.type,
|
||||
};
|
||||
});
|
||||
|
||||
processing.value = true;
|
||||
|
||||
router.patch(
|
||||
route("clientCase.contract.patchMeta", {
|
||||
client_case: props.client_case.uuid,
|
||||
uuid: props.contract.uuid,
|
||||
}),
|
||||
{ meta },
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ["contracts"],
|
||||
onSuccess: () => {
|
||||
close();
|
||||
processing.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" max-width="3xl" @close="close">
|
||||
<template #title>
|
||||
<h3 class="text-lg font-semibold leading-6 text-foreground">Uredi Meta podatke</h3>
|
||||
</template>
|
||||
<template #description>
|
||||
Posodobi meta podatke za pogodbo {{ contract?.reference }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<form id="meta-edit-form" @submit.prevent="submit" class="space-y-4">
|
||||
<ScrollArea class="h-[60vh]">
|
||||
<div class="space-y-3 pr-4">
|
||||
<div
|
||||
v-for="(entry, index) in metaEntries"
|
||||
:key="index"
|
||||
class="flex items-start gap-2 p-3 border rounded-lg bg-muted/20"
|
||||
>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div>
|
||||
<Label :for="`meta-title-${index}`">Naziv</Label>
|
||||
<Input
|
||||
:id="`meta-title-${index}`"
|
||||
v-model="entry.title"
|
||||
placeholder="Vnesi naziv..."
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label :for="`meta-type-${index}`">Tip</Label>
|
||||
<Select v-model="entry.type">
|
||||
<SelectTrigger :id="`meta-type-${index}`" class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">Tekst</SelectItem>
|
||||
<SelectItem value="number">Številka</SelectItem>
|
||||
<SelectItem value="date">Datum</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label :for="`meta-value-${index}`">Vrednost</Label>
|
||||
<Input
|
||||
:id="`meta-value-${index}`"
|
||||
v-model="entry.value"
|
||||
:type="
|
||||
entry.type === 'date'
|
||||
? 'date'
|
||||
: entry.type === 'number'
|
||||
? 'number'
|
||||
: 'text'
|
||||
"
|
||||
:step="entry.type === 'number' ? '0.01' : undefined"
|
||||
placeholder="Vnesi vrednost..."
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="removeEntry(index)"
|
||||
:disabled="metaEntries.length === 1"
|
||||
class="mt-6"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Button type="button" variant="outline" @click="addEntry" class="w-full">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Dodaj vnos
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button type="button" variant="ghost" @click="close" :disabled="processing">
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button type="submit" form="meta-edit-form" :disabled="processing">
|
||||
{{ processing ? "Shranjujem..." : "Shrani" }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -15,6 +15,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
||||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||
import PaymentDialog from "./PaymentDialog.vue";
|
||||
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
||||
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
@@ -33,6 +34,16 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
client: { type: Object, default: null },
|
||||
@@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
|
||||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Meta edit dialog
|
||||
const showMetaEditDialog = ref(false);
|
||||
|
||||
const openMetaEditDialog = (c) => {
|
||||
selectedContract.value = c;
|
||||
showMetaEditDialog.value = true;
|
||||
};
|
||||
|
||||
const closeMetaEditDialog = () => {
|
||||
showMetaEditDialog.value = false;
|
||||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Columns configuration
|
||||
const columns = computed(() => [
|
||||
{ key: "reference", label: "Ref.", sortable: false, align: "center" },
|
||||
@@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
|
||||
<div class="text-gray-500">Ni meta podatkov.</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="edit && row.active" class="border-t border-gray-200 mt-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="openMetaEditDialog(row)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faPenToSquare"
|
||||
class="h-3.5 w-3.5 text-gray-600"
|
||||
/>
|
||||
<span>Uredi meta podatke</span>
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
|
||||
:edit="edit"
|
||||
/>
|
||||
|
||||
<ContractMetaEditDialog
|
||||
:show="showMetaEditDialog"
|
||||
:client_case="client_case"
|
||||
:contract="selectedContract"
|
||||
@close="closeMetaEditDialog"
|
||||
/>
|
||||
|
||||
<!-- Generate Document Dialog -->
|
||||
<CreateDialog
|
||||
:show="showGenerateDialog"
|
||||
@@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
|
||||
@confirm="submitGenerate"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Predloga</label>
|
||||
<select
|
||||
v-model="selectedTemplateSlug"
|
||||
@change="onTemplateChange"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">Izberi predlogo...</option>
|
||||
<option v-for="t in templates" :key="t.slug" :value="t.slug">
|
||||
{{ t.name }} (v{{ t.version }})
|
||||
</option>
|
||||
</select>
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi predlogo..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
|
||||
{{ t.name }} (v{{ t.version }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Custom inputs -->
|
||||
@@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="token in customTokenList" :key="token">
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
<div v-for="token in customTokenList" :key="token" class="space-y-2">
|
||||
<Label>
|
||||
{{ token.replace(/^custom\./, "") }}
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Textarea
|
||||
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
|
||||
v-model="customInputs[token.replace(/^custom\./, '')]"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
rows="3"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="customInputs[token.replace(/^custom\./, '')]"
|
||||
:type="
|
||||
templateCustomTypes[token.replace(/^custom\./, '')] === 'date'
|
||||
? 'date'
|
||||
: templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
|
||||
? 'number'
|
||||
: 'text'
|
||||
"
|
||||
:step="
|
||||
templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
|
||||
? '0.01'
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
|
||||
|
||||
<!-- Address overrides -->
|
||||
<div class="border-t border-gray-200 pt-4 space-y-3">
|
||||
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label>
|
||||
<select
|
||||
v-model="clientAddressSource"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="client">Stranka</option>
|
||||
<option value="case_person">Oseba primera</option>
|
||||
</select>
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
|
||||
<div class="space-y-2">
|
||||
<Label>Naslov stranke</Label>
|
||||
<Select v-model="clientAddressSource">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="client">Stranka</SelectItem>
|
||||
<SelectItem value="case_person">Oseba primera</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label>
|
||||
<select
|
||||
v-model="personAddressSource"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="case_person">Oseba primera</option>
|
||||
<option value="client">Stranka</option>
|
||||
</select>
|
||||
<div class="space-y-2">
|
||||
<Label>Naslov osebe</Label>
|
||||
<Select v-model="personAddressSource">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="case_person">Oseba primera</SelectItem>
|
||||
<SelectItem value="client">Stranka</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ const onDocSaved = () => {
|
||||
router.reload({ only: ["documents"] });
|
||||
};
|
||||
|
||||
const viewer = ref({ open: false, src: "", title: "" });
|
||||
const viewer = ref({ open: false, src: "", title: "", mimeType: "", filename: "" });
|
||||
const openViewer = (doc) => {
|
||||
const kind = classifyDocument(doc);
|
||||
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
|
||||
@@ -122,7 +122,13 @@ const openViewer = (doc) => {
|
||||
client_case: props.client_case.uuid,
|
||||
document: doc.uuid,
|
||||
});
|
||||
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
|
||||
viewer.value = {
|
||||
open: true,
|
||||
src: url,
|
||||
title: doc.name || doc.original_name,
|
||||
mimeType: doc.mime_type || "",
|
||||
filename: doc.original_name || doc.name || "",
|
||||
};
|
||||
} else {
|
||||
const url =
|
||||
isContractDoc && doc.contract_uuid
|
||||
@@ -140,6 +146,8 @@ const openViewer = (doc) => {
|
||||
const closeViewer = () => {
|
||||
viewer.value.open = false;
|
||||
viewer.value.src = "";
|
||||
viewer.value.mimeType = "";
|
||||
viewer.value.filename = "";
|
||||
};
|
||||
|
||||
const clientDetails = ref(false);
|
||||
@@ -210,14 +218,6 @@ const closeDrawer = () => {
|
||||
drawerAddActivity.value = false;
|
||||
};
|
||||
|
||||
const showClientDetails = () => {
|
||||
clientDetails.value = false;
|
||||
};
|
||||
|
||||
const hideClietnDetails = () => {
|
||||
clientDetails.value = true;
|
||||
};
|
||||
|
||||
// Attach segment to case
|
||||
const showAttachSegment = ref(false);
|
||||
const openAttachSegment = () => {
|
||||
@@ -490,6 +490,8 @@ const submitAttachSegment = () => {
|
||||
:show="viewer.open"
|
||||
:src="viewer.src"
|
||||
:title="viewer.title"
|
||||
:mime-type="viewer.mimeType"
|
||||
:filename="viewer.filename"
|
||||
@close="closeViewer"
|
||||
/>
|
||||
</AppLayout>
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { ButtonGroup } from "@/Components/ui/button-group";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { Filter, LinkIcon } from "lucide-vue-next";
|
||||
import { Filter, LinkIcon, FileDown, LayoutIcon } from "lucide-vue-next";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { toNumber } from "lodash";
|
||||
import { FormControl, FormField, FormFieldArray, FormLabel } from "@/Components/ui/form";
|
||||
import { Field, FieldLabel } from "@/Components/ui/field";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { z } from "zod";
|
||||
import FormChangeSegment from "./Partials/FormChangeSegment.vue";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
contracts: Object,
|
||||
filters: Object,
|
||||
segments: Object,
|
||||
segments: Array,
|
||||
types: Object,
|
||||
});
|
||||
|
||||
@@ -54,6 +72,48 @@ const selectedSegments = ref(
|
||||
: []
|
||||
);
|
||||
const filterPopoverOpen = ref(false);
|
||||
const selectedContracts = ref([]);
|
||||
const changeSegmentDialogOpen = ref(false);
|
||||
const contractTable = ref(null);
|
||||
|
||||
const exportDialogOpen = ref(false);
|
||||
const exportScope = ref("current");
|
||||
const exportColumns = ref([
|
||||
"reference",
|
||||
"customer",
|
||||
"address",
|
||||
"start",
|
||||
"segment",
|
||||
"balance",
|
||||
]);
|
||||
const exportError = ref("");
|
||||
const isExporting = ref(false);
|
||||
|
||||
const exportableColumns = [
|
||||
{ key: "reference", label: "Referenca" },
|
||||
{ key: "customer", label: "Stranka" },
|
||||
{ key: "address", label: "Naslov" },
|
||||
{ key: "start", label: "Začetek" },
|
||||
{ key: "segment", label: "Segment" },
|
||||
{ key: "balance", label: "Stanje" },
|
||||
];
|
||||
|
||||
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
|
||||
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
|
||||
const totalContracts = computed(() => props.contracts?.total ?? 0);
|
||||
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
|
||||
const allColumnsSelected = computed(
|
||||
() => exportColumns.value.length === exportableColumns.length
|
||||
);
|
||||
const exportDisabled = computed(
|
||||
() => exportColumns.value.length === 0 || isExporting.value
|
||||
);
|
||||
const segmentSelectItems = computed(() =>
|
||||
props.segments.map((val, i) => ({
|
||||
label: val.name,
|
||||
value: val.id,
|
||||
}))
|
||||
);
|
||||
|
||||
function applyDateFilter() {
|
||||
filterPopoverOpen.value = false;
|
||||
@@ -124,6 +184,157 @@ function formatDate(value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAllColumns(checked) {
|
||||
exportColumns.value = checked ? exportableColumns.map((col) => col.key) : [];
|
||||
}
|
||||
|
||||
function handleColumnToggle(key, checked) {
|
||||
if (checked) {
|
||||
if (!exportColumns.value.includes(key)) {
|
||||
exportColumns.value = [...exportColumns.value, key];
|
||||
}
|
||||
} else {
|
||||
exportColumns.value = exportColumns.value.filter((col) => col !== key);
|
||||
}
|
||||
}
|
||||
|
||||
function setExportScopeFromSwitch(checked) {
|
||||
exportScope.value = checked ? "all" : "current";
|
||||
}
|
||||
|
||||
function openExportDialog() {
|
||||
exportDialogOpen.value = true;
|
||||
exportError.value = "";
|
||||
}
|
||||
|
||||
function closeExportDialog() {
|
||||
exportDialogOpen.value = false;
|
||||
}
|
||||
|
||||
async function submitExport() {
|
||||
if (exportColumns.value.length === 0) {
|
||||
exportError.value = "Izberi vsaj en stolpec.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
exportError.value = "";
|
||||
isExporting.value = true;
|
||||
|
||||
const payload = {
|
||||
scope: exportScope.value,
|
||||
columns: [...exportColumns.value],
|
||||
from: dateRange.value?.start || "",
|
||||
to: dateRange.value?.end || "",
|
||||
search: search.value || "",
|
||||
segments: selectedSegments.value.length > 0 ? selectedSegments.value.join(",") : "",
|
||||
page: contractsCurrentPage.value,
|
||||
per_page: contractsPerPage.value,
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
route("client.contracts.export", { uuid: props.client.uuid }),
|
||||
payload,
|
||||
{ responseType: "blob" }
|
||||
);
|
||||
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const filename =
|
||||
extractFilenameFromHeaders(response.headers) || buildDefaultFilename();
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
exportDialogOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error("Export error:", error);
|
||||
console.error("Error response:", error.response);
|
||||
|
||||
let errorMessage = "Izvoz je spodletel. Poskusi znova.";
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
errorMessage = "Pot za izvoz ne obstaja. Prosim kontaktiraj administratorja.";
|
||||
} else if (error.response?.status === 500) {
|
||||
errorMessage = "Napaka na strežniku. Poskusi znova.";
|
||||
} else if (error.response?.data) {
|
||||
try {
|
||||
const text = await error.response.data.text();
|
||||
const json = JSON.parse(text);
|
||||
errorMessage = json.message || errorMessage;
|
||||
} catch (e) {
|
||||
console.error("Could not parse error response:", e);
|
||||
}
|
||||
}
|
||||
|
||||
exportError.value = errorMessage;
|
||||
} finally {
|
||||
isExporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
if (!value) {
|
||||
return "data";
|
||||
}
|
||||
const slug = value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
|
||||
return slug || "data";
|
||||
}
|
||||
|
||||
function buildDefaultFilename() {
|
||||
const now = new Date();
|
||||
const dd = String(now.getDate()).padStart(2, "0");
|
||||
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const yy = String(now.getFullYear()).slice(-2);
|
||||
const clientName = props.client?.person?.full_name || "stranka";
|
||||
return `${dd}${mm}${yy}_${slugify(clientName)}-Pogodbe.xlsx`;
|
||||
}
|
||||
|
||||
function extractFilenameFromHeaders(headers) {
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
const disposition =
|
||||
headers["content-disposition"] || headers["Content-Disposition"] || "";
|
||||
if (!disposition) {
|
||||
return null;
|
||||
}
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch (error) {
|
||||
return utf8Match[1];
|
||||
}
|
||||
}
|
||||
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||
return asciiMatch?.[1] || null;
|
||||
}
|
||||
|
||||
function handleSelectionChange(selectedKeys) {
|
||||
selectedContracts.value = selectedKeys.map((val, i) => {
|
||||
const num = toNumber(val);
|
||||
|
||||
return props.contracts.data[num].uuid;
|
||||
});
|
||||
}
|
||||
|
||||
function openDialogChangeSegment() {
|
||||
changeSegmentDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function clearContractTableSelected() {
|
||||
if (contractTable.value) {
|
||||
contractTable.value.clearSelection();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -193,9 +404,11 @@ function formatDate(value) {
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
ref="contractTable"
|
||||
:columns="[
|
||||
{ key: 'reference', label: 'Referenca', sortable: false },
|
||||
{ key: 'customer', label: 'Stranka', sortable: false },
|
||||
{ key: 'address', label: 'Naslov', sortable: false },
|
||||
{ key: 'start', label: 'Začetek', sortable: false },
|
||||
{ key: 'segment', label: 'Segment', sortable: false },
|
||||
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
|
||||
@@ -215,94 +428,136 @@ function formatDate(value) {
|
||||
row-key="uuid"
|
||||
:only-props="['contracts']"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:enable-row-selection="true"
|
||||
@selection:change="handleSelectionChange"
|
||||
page-param-name="contracts_page"
|
||||
per-page-param-name="contracts_per_page"
|
||||
:show-toolbar="true"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[400px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="
|
||||
dateRange?.start || dateRange?.end || selectedSegments?.length
|
||||
"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{
|
||||
[
|
||||
dateRange?.start || dateRange?.end ? 1 : 0,
|
||||
selectedSegments?.length ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0)
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri pogodb</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite filtre za prikaz pogodb
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Iskanje</InputLabel>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po referenci, stranki..."
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datumska območja</InputLabel>
|
||||
<DateRangePicker
|
||||
v-model="dateRange"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datumska območja"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Segmenti</InputLabel>
|
||||
<AppMultiSelect
|
||||
v-model="selectedSegments"
|
||||
:items="
|
||||
segments.map((s) => ({ value: String(s.id), label: s.name }))
|
||||
<template #toolbar-filters="{ table }">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[400px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="
|
||||
dateRange?.start || dateRange?.end || selectedSegments?.length
|
||||
"
|
||||
placeholder="Vsi segmenti"
|
||||
search-placeholder="Išči segment..."
|
||||
empty-text="Ni segmentov"
|
||||
chip-variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="
|
||||
!dateRange?.start &&
|
||||
!dateRange?.end &&
|
||||
selectedSegments.length === 0 &&
|
||||
search === ''
|
||||
"
|
||||
@click="clearDateFilter"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyDateFilter">
|
||||
Uporabi
|
||||
</Button>
|
||||
{{
|
||||
[
|
||||
dateRange?.start || dateRange?.end ? 1 : 0,
|
||||
selectedSegments?.length ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0)
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri pogodb</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite filtre za prikaz pogodb
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Iskanje</InputLabel>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po referenci, stranki..."
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datumska območja</InputLabel>
|
||||
<DateRangePicker
|
||||
v-model="dateRange"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datumska območja"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Segmenti</InputLabel>
|
||||
<AppMultiSelect
|
||||
v-model="selectedSegments"
|
||||
:items="
|
||||
segments.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
}))
|
||||
"
|
||||
placeholder="Vsi segmenti"
|
||||
search-placeholder="Išči segment..."
|
||||
empty-text="Ni segmentov"
|
||||
chip-variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="
|
||||
!dateRange?.start &&
|
||||
!dateRange?.end &&
|
||||
selectedSegments.length === 0 &&
|
||||
search === ''
|
||||
"
|
||||
@click="clearDateFilter"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyDateFilter">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</AppPopover>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
@click="openExportDialog"
|
||||
>
|
||||
<FileDown class="h-4 w-4" />
|
||||
Izvozi v Excel
|
||||
</Button>
|
||||
<DropdownMenu v-if="table.getSelectedRowModel().rows.length > 0">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button class="gap-2 px-3" variant="outline">
|
||||
<Badge
|
||||
class="h-5 min-w-5 rounded-full font-mono tabular-nums text-accent"
|
||||
variant="destructive"
|
||||
>
|
||||
{{ table.getSelectedRowModel().rows.length }}
|
||||
</Badge>
|
||||
Akcija
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem @click="openDialogChangeSegment">
|
||||
<LayoutIcon />
|
||||
Spremeni segment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="clearContractTableSelected"
|
||||
v-if="table.getSelectedRowModel().rows.length > 0"
|
||||
>
|
||||
Odznači izbrane
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-reference="{ row }">
|
||||
<Link
|
||||
@@ -315,6 +570,9 @@ function formatDate(value) {
|
||||
<template #cell-customer="{ row }">
|
||||
{{ row.client_case?.person?.full_name || "-" }}
|
||||
</template>
|
||||
<template #cell-address="{ row }">
|
||||
{{ row.client_case?.person?.address?.address || "-" }}
|
||||
</template>
|
||||
<template #cell-start="{ row }">
|
||||
{{ formatDate(row.start_date) }}
|
||||
</template>
|
||||
@@ -337,5 +595,122 @@ function formatDate(value) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Excel export dialog -->
|
||||
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
|
||||
<template #title>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-semibold leading-6 text-foreground">Izvoz v Excel</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberi stolpce in obseg podatkov za izvoz.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<form id="contract-export-form" class="space-y-5" @submit.prevent="submitExport">
|
||||
<div class="space-y-3 rounded-lg border bg-muted/40 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-foreground">Obseg podatkov</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Preklopi, ali izvoziš samo trenutni pogled ali vse pogodbe.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md bg-background px-3 py-2 shadow-sm"
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground">Stran</span>
|
||||
<Switch
|
||||
:model-value="exportScope === 'all'"
|
||||
@update:modelValue="setExportScopeFromSwitch"
|
||||
aria-label="Preklopi obseg izvoza"
|
||||
/>
|
||||
<span class="text-xs font-medium text-muted-foreground">Vse</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="rounded-lg border bg-background p-3 shadow-sm">
|
||||
<p class="text-sm font-semibold text-foreground">Trenutna stran</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ currentPageCount }} zapisov
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background p-3 shadow-sm">
|
||||
<p class="text-sm font-semibold text-foreground">Vse pogodbe</p>
|
||||
<p class="text-xs text-muted-foreground">{{ totalContracts }} zapisov</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border bg-muted/40 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-foreground">Stolpci</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberi, katere stolpce želiš vključiti v izvoz.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-columns-all"
|
||||
:model-value="allColumnsSelected"
|
||||
@update:modelValue="toggleAllColumns"
|
||||
aria-label="Označi vse stolpce"
|
||||
/>
|
||||
<Label for="export-columns-all" class="text-sm text-muted-foreground">
|
||||
Označi vse
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="col in exportableColumns"
|
||||
:key="col.key"
|
||||
class="flex items-start gap-3 rounded-lg border bg-background px-3 py-3 text-sm shadow-sm transition hover:border-primary/40"
|
||||
:for="`export-col-${col.key}`"
|
||||
>
|
||||
<Checkbox
|
||||
:id="`export-col-${col.key}`"
|
||||
:model-value="exportColumns.includes(col.key)"
|
||||
:value="col.key"
|
||||
@update:modelValue="(checked) => handleColumnToggle(col.key, checked)"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<p class="font-medium text-foreground">{{ col.label }}</p>
|
||||
<p class="text-xs text-muted-foreground">Vključi stolpec v datoteko.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button type="button" variant="ghost" @click="closeExportDialog">
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="contract-export-form"
|
||||
:disabled="exportDisabled"
|
||||
class="gap-2"
|
||||
>
|
||||
<span v-if="!isExporting">Prenesi Excel</span>
|
||||
<span v-else>Pripravljam ...</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- Change segment selected contracts dialog -->
|
||||
|
||||
<FormChangeSegment
|
||||
:show="changeSegmentDialogOpen"
|
||||
@close="changeSegmentDialogOpen = false"
|
||||
:segments="segmentSelectItems"
|
||||
:contracts="selectedContracts"
|
||||
:clear-selected-rows="clearContractTableSelected"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -6,10 +6,8 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card";
|
||||
import { CardTitle } from "@/Components/ui/card";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,8 +25,7 @@ import {
|
||||
import { useForm } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import { Mail, Plug2Icon, Plus, UsersRoundIcon } from "lucide-vue-next";
|
||||
import { Plus, UsersRoundIcon } from "lucide-vue-next";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
|
||||
@@ -162,7 +159,7 @@ const fmtCurrency = (v) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<AppLayout title="Clients">
|
||||
<template #header> </template>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@@ -201,6 +198,7 @@ const fmtCurrency = (v) => {
|
||||
:show-pagination="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
:page-size="100"
|
||||
row-key="uuid"
|
||||
:striped="true"
|
||||
empty-text="Ni najdenih naročnikov."
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/Components/ui/field";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm, Field as VeeField } from "vee-validate";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import { onMounted, ref } from "vue";
|
||||
import z from "zod";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
segments: { type: Array, default: [] },
|
||||
contracts: { type: Array, default: [] },
|
||||
clearSelectedRows: { type: Function, default: () => console.log("test") },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const processing = ref(false);
|
||||
|
||||
// vee-validate Form setup
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
segment_id: z
|
||||
.number()
|
||||
.refine((val) => props.segments.find((item) => item.value == val) !== undefined, {
|
||||
message: "Izbran segment ne obstaja v zbirki segmentov",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const { handleSubmit, resetForm, errors } = useForm({
|
||||
validationSchema: formSchema,
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
processing.value = true;
|
||||
router.patch(
|
||||
route("contracts.segment"),
|
||||
{
|
||||
...data,
|
||||
contracts: props.contracts,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.reload({ only: ["contracts"] });
|
||||
close();
|
||||
resetForm();
|
||||
props.clearSelectedRows();
|
||||
processing.value = false;
|
||||
},
|
||||
onError: (e) => {
|
||||
errors = e;
|
||||
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
console.log(props.segments);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
<h3 class="text-lg font-semibold leading-6 text-foreground">
|
||||
Spremeni segment pogodbam
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<form id="segment-change-form" @submit.prevent="onSubmit">
|
||||
<VeeField v-slot="{ field, errors }" name="segment_id">
|
||||
<Field orientation="responsive" :data-invalid="!!errors.length">
|
||||
<FieldContent>
|
||||
<FieldLabel for="segment">Segment</FieldLabel>
|
||||
<FieldDescription>Izberi segment za preusmeritev</FieldDescription>
|
||||
<FieldError v-if="errors.length" :errors="errors" />
|
||||
</FieldContent>
|
||||
|
||||
<Select
|
||||
:model-value="field.value"
|
||||
@update:model-value="field.onChange"
|
||||
@blur="field.onBlur"
|
||||
>
|
||||
<SelectTrigger id="segment_id" :aria-invalid="!!errors.length">
|
||||
<SelectValue placeholder="Izberi segment..."></SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="item-aligned">
|
||||
<SelectItem value="auto"> Auto </SelectItem>
|
||||
<SelectItem
|
||||
v-for="segment in segments"
|
||||
:key="segment.label"
|
||||
:value="segment.value"
|
||||
>
|
||||
{{ segment.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</VeeField>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
:disabled="processing"
|
||||
variant="ghost"
|
||||
@click="
|
||||
() => {
|
||||
close();
|
||||
resetForm();
|
||||
}
|
||||
"
|
||||
>
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button type="submit" form="segment-change-form" :disabled="processing">
|
||||
Potrdi
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
@@ -30,14 +30,15 @@ import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { toNumber } from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
setting: Object,
|
||||
unassignedContracts: Object,
|
||||
assignedContracts: Object,
|
||||
users: Array,
|
||||
unassignedClients: Array,
|
||||
assignedClients: Array,
|
||||
unassignedClients: [Array, Object],
|
||||
assignedClients: [Array, Object],
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
@@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
|
||||
: []
|
||||
);
|
||||
|
||||
const unassignedContractTable = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
contract_uuid: null,
|
||||
assigned_user_id: null,
|
||||
@@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
|
||||
console.log(selectedContractUuids.value);
|
||||
}
|
||||
|
||||
function handleContractSelection(selected) {
|
||||
selectedContractUuids.value = selected.map((val, i) => {
|
||||
const num = toNumber(val);
|
||||
|
||||
return props.unassignedContracts.data[num].uuid;
|
||||
});
|
||||
}
|
||||
|
||||
// Format helpers (Slovenian formatting)
|
||||
|
||||
// Initialize search and filter from URL params
|
||||
@@ -296,6 +307,7 @@ function assignSelected() {
|
||||
bulkForm.contract_uuids = selectedContractUuids.value;
|
||||
bulkForm.post(route("fieldjobs.assign-bulk"), {
|
||||
onSuccess: () => {
|
||||
unassignedContractTable.value.clearSelection();
|
||||
selectedContractUuids.value = [];
|
||||
bulkForm.contract_uuids = [];
|
||||
},
|
||||
@@ -304,7 +316,11 @@ function assignSelected() {
|
||||
|
||||
function cancelAssignment(contract) {
|
||||
const payload = { contract_uuid: contract.uuid };
|
||||
form.transform(() => payload).post(route("fieldjobs.cancel"));
|
||||
form
|
||||
.transform(() => payload)
|
||||
.post(route("fieldjobs.cancel"), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Column definitions for DataTableNew2
|
||||
@@ -437,6 +453,7 @@ const assignedRows = computed(() =>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
ref="unassignedContractTable"
|
||||
:columns="unassignedColumns"
|
||||
:data="unassignedRows"
|
||||
:meta="{
|
||||
@@ -449,6 +466,8 @@ const assignedRows = computed(() =>
|
||||
links: unassignedContracts.links,
|
||||
}"
|
||||
row-key="uuid"
|
||||
:enable-row-selection="true"
|
||||
@selection:change="handleContractSelection"
|
||||
:page-size="props.unassignedContracts?.per_page || 10"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
@@ -482,7 +501,10 @@ const assignedRows = computed(() =>
|
||||
<AppMultiSelect
|
||||
v-model="filterUnassignedSelectedClient"
|
||||
:items="
|
||||
(props.unassignedClients || []).map((client) => ({
|
||||
(Array.isArray(props.unassignedClients)
|
||||
? props.unassignedClients
|
||||
: props.unassignedClients?.data || []
|
||||
).map((client) => ({
|
||||
value: client.uuid,
|
||||
label: client.person.full_name,
|
||||
}))
|
||||
@@ -497,14 +519,6 @@ const assignedRows = computed(() =>
|
||||
</AppPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-_select="{ row }">
|
||||
<Checkbox
|
||||
@update:model-value="
|
||||
(checked) => toggleContractSelection(row.uuid, checked)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-case_person="{ row }">
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
@@ -605,7 +619,10 @@ const assignedRows = computed(() =>
|
||||
<AppMultiSelect
|
||||
v-model="filterAssignedSelectedClient"
|
||||
:items="
|
||||
(props.assignedClients || []).map((client) => ({
|
||||
(Array.isArray(props.assignedClients)
|
||||
? props.assignedClients
|
||||
: props.assignedClients?.data || []
|
||||
).map((client) => ({
|
||||
value: client.uuid,
|
||||
label: client.person.full_name,
|
||||
}))
|
||||
|
||||
@@ -245,7 +245,7 @@ async function startImport() {
|
||||
|
||||
<!-- Has Header Checkbox -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="has-header" v-model:checked="form.has_header" />
|
||||
<Checkbox id="has-header" :model-value="form.has_header" />
|
||||
<Label
|
||||
for="has-header"
|
||||
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
|
||||
@@ -1094,6 +1094,16 @@ async function fetchEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImport() {
|
||||
if (!importId.value) return;
|
||||
try {
|
||||
const url = route("imports.download", { import: importId.value });
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
console.error("Download failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Simulation (generic or payments) state
|
||||
const showPaymentSim = ref(false);
|
||||
const paymentSimLoading = ref(false);
|
||||
@@ -1307,7 +1317,8 @@ async function fetchSimulation() {
|
||||
<Checkbox
|
||||
:id="'show-missing-checkbox'"
|
||||
:checked="showMissingEnabled"
|
||||
@update:checked="
|
||||
:model-value="showMissingEnabled"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
showMissingEnabled = val;
|
||||
saveImportOptions();
|
||||
@@ -1339,6 +1350,7 @@ async function fetchSimulation() {
|
||||
:can-process="canProcess"
|
||||
:selected-mappings-count="selectedMappingsCount"
|
||||
@preview="openPreview"
|
||||
@download="downloadImport"
|
||||
@save-mappings="saveMappings"
|
||||
@process-import="processImport"
|
||||
@simulate="openSimulation"
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
ArrowPathIcon,
|
||||
BeakerIcon,
|
||||
ArrowDownOnSquareIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
importId: [Number, String],
|
||||
@@ -16,54 +17,68 @@ const props = defineProps({
|
||||
canProcess: Boolean,
|
||||
selectedMappingsCount: Number,
|
||||
});
|
||||
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]);
|
||||
const emits = defineEmits([
|
||||
"preview",
|
||||
"save-mappings",
|
||||
"process-import",
|
||||
"simulate",
|
||||
"download",
|
||||
]);
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<!-- Download button - always visible -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click.prevent="$emit('preview')"
|
||||
@click.prevent="$emit('download')"
|
||||
:disabled="!importId"
|
||||
title="Preznesi originalno uvozno datoteko"
|
||||
>
|
||||
<EyeIcon class="h-4 w-4 mr-2" />
|
||||
Predogled vrstic
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-orange-600 hover:bg-orange-700"
|
||||
@click.prevent="$emit('save-mappings')"
|
||||
:disabled="!importId || processing || savingMappings || isCompleted"
|
||||
title="Shrani preslikave za ta uvoz"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
|
||||
<span>Shrani preslikave</span>
|
||||
<Badge
|
||||
v-if="selectedMappingsCount"
|
||||
variant="secondary"
|
||||
class="ml-2 text-xs"
|
||||
>{{ selectedMappingsCount }}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-purple-600 hover:bg-purple-700"
|
||||
@click.prevent="$emit('process-import')"
|
||||
:disabled="!canProcess"
|
||||
>
|
||||
<BeakerIcon class="h-4 w-4 mr-2" />
|
||||
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-blue-600 hover:bg-blue-700"
|
||||
@click.prevent="$emit('simulate')"
|
||||
:disabled="!importId || processing"
|
||||
>
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
|
||||
Simulacija vnosa
|
||||
<ArrowDownTrayIcon class="h-4 w-4" />
|
||||
Prenos datoteko
|
||||
</Button>
|
||||
|
||||
<!-- Other action buttons - only when not completed -->
|
||||
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||
<Button variant="secondary" @click.prevent="$emit('preview')" :disabled="!importId">
|
||||
<EyeIcon class="h-4 w-4 mr-2" />
|
||||
Predogled vrstic
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-orange-600 hover:bg-orange-700"
|
||||
@click.prevent="$emit('save-mappings')"
|
||||
:disabled="!importId || processing || savingMappings || isCompleted"
|
||||
title="Shrani preslikave za ta uvoz"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
|
||||
<span>Shrani preslikave</span>
|
||||
<Badge v-if="selectedMappingsCount" variant="secondary" class="ml-2 text-xs">{{
|
||||
selectedMappingsCount
|
||||
}}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-purple-600 hover:bg-purple-700"
|
||||
@click.prevent="$emit('process-import')"
|
||||
:disabled="!canProcess"
|
||||
>
|
||||
<BeakerIcon class="h-4 w-4 mr-2" />
|
||||
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-blue-600 hover:bg-blue-700"
|
||||
@click.prevent="$emit('simulate')"
|
||||
:disabled="!importId || processing"
|
||||
>
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
|
||||
Simulacija vnosa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { ChevronRightIcon } from "@heroicons/vue/24/outline";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
limit: Number,
|
||||
@@ -14,72 +17,187 @@ const props = defineProps({
|
||||
truncated: Boolean,
|
||||
hasHeader: Boolean,
|
||||
})
|
||||
|
||||
const emits = defineEmits(['close','change-limit','refresh'])
|
||||
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
|
||||
|
||||
// State
|
||||
const selectedRow = ref(null);
|
||||
const hideEmptyRows = ref(true);
|
||||
|
||||
// Filter out columns with empty headers
|
||||
const visibleColumns = computed(() => {
|
||||
if (!props.columns) return [];
|
||||
return props.columns.filter(col => col && String(col).trim() !== '');
|
||||
});
|
||||
|
||||
// Check if row is empty (first 2 columns are empty)
|
||||
function isRowEmpty(row) {
|
||||
if (!visibleColumns.value || visibleColumns.value.length === 0) return false;
|
||||
const firstCols = visibleColumns.value.slice(0, 2);
|
||||
return firstCols.every(col => !row[col] || String(row[col]).trim() === '');
|
||||
}
|
||||
|
||||
// Filtered rows
|
||||
const visibleRows = computed(() => {
|
||||
if (!props.rows) return [];
|
||||
let filtered = props.rows;
|
||||
if (hideEmptyRows.value) {
|
||||
filtered = filtered.filter(r => !isRowEmpty(r));
|
||||
}
|
||||
return filtered.map((r, idx) => ({ ...r, index: idx + 1 }));
|
||||
});
|
||||
|
||||
// Select row
|
||||
function selectRow(row) {
|
||||
selectedRow.value = row;
|
||||
}
|
||||
|
||||
function onLimit(val) {
|
||||
emits('change-limit', Number(val));
|
||||
emits('refresh');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
|
||||
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-3 pb-3 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
|
||||
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }">
|
||||
<SelectTrigger id="limit-select" class="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="300">300</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">CSV Preview</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Showing {{ visibleRows.length }} of {{ rows.length }} rows
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
|
||||
<Select :model-value="String(limit)" @update:model-value="onLimit">
|
||||
<SelectTrigger id="limit-select" class="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="300">300</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="hide-empty-rows"
|
||||
:checked="hideEmptyRows"
|
||||
@update:checked="(val) => hideEmptyRows = val"
|
||||
/>
|
||||
<Label for="hide-empty-rows" class="text-xs cursor-pointer">
|
||||
Hide empty rows
|
||||
</Label>
|
||||
</div>
|
||||
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
|
||||
Truncated at limit
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</Button>
|
||||
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
|
||||
Truncated at limit
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 bg-white z-10">
|
||||
<TableRow>
|
||||
<TableHead class="w-16">#</TableHead>
|
||||
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="(r, idx) in rows" :key="idx">
|
||||
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
|
||||
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
|
||||
{{ r[col] }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loading && !rows.length">
|
||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
||||
No rows
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<!-- Split View -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left Panel - Row List -->
|
||||
<div class="w-96 border-r bg-gray-50 overflow-y-auto">
|
||||
<div v-if="loading" class="p-8 text-center text-gray-500">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
Loading...
|
||||
</div>
|
||||
<div v-else-if="!visibleRows.length" class="p-8 text-center text-gray-500">
|
||||
No rows to display
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<button
|
||||
v-for="row in visibleRows"
|
||||
:key="row.index"
|
||||
@click="selectRow(row)"
|
||||
class="w-full px-4 py-3 text-left hover:bg-white transition-colors"
|
||||
:class="{
|
||||
'bg-white shadow-sm': selectedRow?.index === row.index,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<!-- Row Number -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-semibold">
|
||||
{{ row.index }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row Preview -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-semibold text-gray-900 mb-1">
|
||||
Row #{{ row.index }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 truncate">
|
||||
{{
|
||||
visibleColumns.slice(0, 2).map(col => row[col]).filter(Boolean).join(' • ') || 'Empty row'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<ChevronRightIcon class="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Row Details -->
|
||||
<div v-if="selectedRow" class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Row Header -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
Row #{{ selectedRow.index }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">Full row details</p>
|
||||
</div>
|
||||
|
||||
<!-- Row Data -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<dl class="grid grid-cols-1 gap-3">
|
||||
<div
|
||||
v-for="col in visibleColumns"
|
||||
:key="col"
|
||||
class="flex items-start gap-3 py-2 border-b border-gray-200 last:border-0"
|
||||
>
|
||||
<dt class="text-sm font-medium text-gray-600 w-48 flex-shrink-0">
|
||||
{{ col }}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 flex-1 font-medium whitespace-pre-wrap break-words">
|
||||
{{ selectedRow[col] || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State for Right Panel -->
|
||||
<div v-else class="flex-1 flex items-center justify-center text-gray-400">
|
||||
<div class="text-center">
|
||||
<div class="text-5xl mb-3">📄</div>
|
||||
<p class="text-sm">Select a row to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 pt-3 border-t">
|
||||
Showing up to {{ limit }} rows from source file.
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500">
|
||||
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
|
||||
• Click a row to view full details
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<script setup>
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
|
||||
const props = defineProps({
|
||||
rows: Array,
|
||||
@@ -19,12 +33,12 @@ const props = defineProps({
|
||||
mappingError: String,
|
||||
show: { type: Boolean, default: true },
|
||||
fieldsForEntity: Function,
|
||||
})
|
||||
const emits = defineEmits(['update:rows','save'])
|
||||
});
|
||||
const emits = defineEmits(["update:rows", "save"]);
|
||||
|
||||
function duplicateTarget(row){
|
||||
if(!row || !row.entity || !row.field) return false
|
||||
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
|
||||
function duplicateTarget(row) {
|
||||
if (!row || !row.entity || !row.field) return false;
|
||||
return props.duplicateTargets?.has?.(row.entity + "." + row.field) || false;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
@@ -32,137 +46,192 @@ function duplicateTarget(row){
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">
|
||||
Detected Columns
|
||||
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge>
|
||||
<Badge variant="outline" class="ml-2 text-[10px]">{{
|
||||
detected?.has_header ? "header" : "positional"
|
||||
}}</Badge>
|
||||
</h3>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}
|
||||
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }},
|
||||
delimiter: {{ detected?.delimiter || "auto" }}
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p>
|
||||
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">
|
||||
{{ detectedNote }}
|
||||
</p>
|
||||
<div class="relative border rounded-lg">
|
||||
<ScrollArea class="h-[420px]">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow class="hover:bg-transparent">
|
||||
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead>
|
||||
<TableHead class="w-[180px] bg-muted/95 backdrop-blur"
|
||||
>Source column</TableHead
|
||||
>
|
||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
|
||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
|
||||
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
|
||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
|
||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
|
||||
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
|
||||
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
|
||||
<TableHead class="w-[130px] bg-muted/95 backdrop-blur"
|
||||
>Apply mode</TableHead
|
||||
>
|
||||
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur"
|
||||
>Skip</TableHead
|
||||
>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''">
|
||||
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue placeholder="Select entity..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.field || ''"
|
||||
@update:model-value="(val) => row.field = val || ''"
|
||||
:disabled="isCompleted"
|
||||
:class="duplicateTarget(row) ? 'border-destructive' : ''"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
v-if="row.field === 'meta'"
|
||||
v-model="(row.options ||= {}).key"
|
||||
type="text"
|
||||
class="h-8 text-xs"
|
||||
placeholder="e.g. monthly_rent"
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
v-if="row.field === 'meta'"
|
||||
:model-value="(row.options ||= {}).type || 'string'"
|
||||
@update:model-value="(val) => (row.options ||= {}).type = val"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="date">date</SelectItem>
|
||||
<SelectItem value="boolean">boolean</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="trim">Trim</SelectItem>
|
||||
<SelectItem value="upper">Uppercase</SelectItem>
|
||||
<SelectItem value="lower">Lowercase</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="keyref">Keyref</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
<SelectItem value="insert">Insert only</SelectItem>
|
||||
<SelectItem value="update">Update only</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableRow
|
||||
v-for="(row, idx) in rows"
|
||||
:key="idx"
|
||||
:class="duplicateTarget(row) ? 'bg-destructive/10' : ''"
|
||||
>
|
||||
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.entity || ''"
|
||||
@update:model-value="(val) => (row.entity = val || '')"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue placeholder="Select entity..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="opt in entityOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>{{ opt.label }}</SelectItem
|
||||
>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.field || ''"
|
||||
@update:model-value="(val) => (row.field = val || '')"
|
||||
:disabled="isCompleted"
|
||||
:class="duplicateTarget(row) ? 'border-destructive' : ''"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-8 text-xs"
|
||||
:class="
|
||||
duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''
|
||||
"
|
||||
>
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="f in fieldsForEntity(row.entity)"
|
||||
:key="f"
|
||||
:value="f"
|
||||
>{{ f }}</SelectItem
|
||||
>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
v-if="row.field === 'meta'"
|
||||
v-model="(row.options ||= {}).key"
|
||||
type="text"
|
||||
class="h-8 text-xs"
|
||||
placeholder="e.g. monthly_rent"
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
v-if="row.field === 'meta'"
|
||||
:model-value="(row.options ||= {}).type || 'string'"
|
||||
@update:model-value="(val) => ((row.options ||= {}).type = val)"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="date">date</SelectItem>
|
||||
<SelectItem value="boolean">boolean</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.transform || 'none'"
|
||||
@update:model-value="
|
||||
(val) => (row.transform = val === 'none' ? '' : val)
|
||||
"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="trim">Trim</SelectItem>
|
||||
<SelectItem value="upper">Uppercase</SelectItem>
|
||||
<SelectItem value="lower">Lowercase</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.apply_mode || 'both'"
|
||||
@update:model-value="(val) => (row.apply_mode = val)"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="keyref">Keyref</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
<SelectItem value="insert">Insert only</SelectItem>
|
||||
<SelectItem value="update">Update only</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Checkbox
|
||||
:model-value="row.skip"
|
||||
@update:model-value="(val) => (row.skip = val)"
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2">
|
||||
<div
|
||||
v-if="mappingSaved"
|
||||
class="text-sm text-emerald-700 mt-2 flex items-center gap-2"
|
||||
>
|
||||
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
|
||||
<span>{{ mappingSavedCount }} mappings saved</span>
|
||||
</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">
|
||||
{{ mappingError }}
|
||||
</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2">
|
||||
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
|
||||
<Badge variant="destructive" class="text-xs"
|
||||
>Missing critical: {{ missingCritical.join(", ") }}</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Bell, BellOff, Check, ChevronDown, X, Inbox } from "lucide-vue-next";
|
||||
import TableActions from "@/Components/DataTable/TableActions.vue";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { number } from "zod";
|
||||
import { toNumber } from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
activities: { type: Object, required: true },
|
||||
today: { type: String, required: true },
|
||||
// Optional: full list of clients with unread items to populate filter dropdown
|
||||
clients: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
@@ -21,6 +46,7 @@ function fmtDate(d) {
|
||||
return String(d);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtEUR(value) {
|
||||
if (value === null || value === undefined) return "—";
|
||||
const num = typeof value === "string" ? Number(value) : value;
|
||||
@@ -34,13 +60,12 @@ function fmtEUR(value) {
|
||||
return formatted.replace("\u00A0", " ");
|
||||
}
|
||||
|
||||
// --- Client filter (like Segments/Show.vue) ---
|
||||
// Client filter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
|
||||
const selectedClient = ref(initialClient);
|
||||
|
||||
const clientOptions = computed(() => {
|
||||
// Prefer server-provided clients list; fallback to deriving from rows
|
||||
const list =
|
||||
Array.isArray(props.clients) && props.clients.length
|
||||
? props.clients
|
||||
@@ -72,43 +97,20 @@ watch(selectedClient, (val) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Row selection - connected to DataTableNew2's built-in selection
|
||||
const selectedRows = ref([]);
|
||||
const dataTableRef = ref(null);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectedRows.value.length === (props.activities.data?.length || 0)) {
|
||||
selectedRows.value = [];
|
||||
} else {
|
||||
selectedRows.value = (props.activities.data || []).map((row) => row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRowSelection(id) {
|
||||
const idx = selectedRows.value.indexOf(id);
|
||||
if (idx > -1) {
|
||||
selectedRows.value.splice(idx, 1);
|
||||
} else {
|
||||
selectedRows.value.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
function isRowSelected(id) {
|
||||
return selectedRows.value.includes(id);
|
||||
}
|
||||
|
||||
function isAllSelected() {
|
||||
return (
|
||||
(props.activities.data?.length || 0) > 0 &&
|
||||
selectedRows.value.length === (props.activities.data?.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
function isIndeterminate() {
|
||||
return (
|
||||
selectedRows.value.length > 0 &&
|
||||
selectedRows.value.length < (props.activities.data?.length || 0)
|
||||
);
|
||||
function handleSelectionChange(selectedKeys) {
|
||||
selectedRows.value = selectedKeys.map((val, i) => {
|
||||
const nu = toNumber(val);
|
||||
return props.activities.data[val].id;
|
||||
});
|
||||
|
||||
console.log(selectedRows.value);
|
||||
}
|
||||
|
||||
// Mark as read actions
|
||||
function markRead(id) {
|
||||
router.patch(
|
||||
route("notifications.activity.read"),
|
||||
@@ -130,143 +132,131 @@ function markReadBulk() {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
selectedRows.value = [];
|
||||
// Clear the selection state in DataTable
|
||||
if (dataTableRef.value) {
|
||||
dataTableRef.value.clearSelection();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Table columns definition (select column is auto-generated by enableRowSelection)
|
||||
const columns = [
|
||||
{ key: "what", label: "Zadeva", sortable: false },
|
||||
{ key: "partner", label: "Partner", sortable: false },
|
||||
{ key: "balance", label: "Stanje", sortable: false, align: "right" },
|
||||
{ key: "due", label: "Zapadlost", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
|
||||
];
|
||||
|
||||
const rows = computed(() => props.activities?.data || []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Obvestila">
|
||||
<template #header></template>
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3">
|
||||
<div class="pb-3">
|
||||
<SectionTitle>
|
||||
<template #title>Neprikazana obvestila</template>
|
||||
<template #description>Do danes: {{ fmtDate(today) }}</template>
|
||||
</SectionTitle>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex-1 max-w-sm">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Partner</label
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"
|
||||
>
|
||||
<Bell class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Neprikazana obvestila</CardTitle>
|
||||
<CardDescription>Do danes: {{ fmtDate(today) }}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<!-- Client Filter -->
|
||||
<div class="mb-6 px-6 flex items-end gap-3">
|
||||
<div class="flex-1 max-w-sm space-y-2">
|
||||
<Label for="client-filter">Partner</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="selectedClient"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
|
||||
>
|
||||
<option value="">Vsi partnerji</option>
|
||||
<option
|
||||
v-for="opt in clientOptions"
|
||||
:key="opt.value || opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
<Select v-model="selectedClient">
|
||||
<SelectTrigger id="client-filter">
|
||||
<SelectValue placeholder="Vsi partnerji" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in clientOptions"
|
||||
:key="opt.value || opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
v-if="selectedClient"
|
||||
type="button"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="selectedClient = ''"
|
||||
title="Počisti filter"
|
||||
>
|
||||
Počisti
|
||||
</button>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTableServer
|
||||
:columns="[
|
||||
{ key: 'select', label: '', sortable: false, width: '50px' },
|
||||
{ key: 'what', label: 'Zadeva', sortable: false },
|
||||
{ key: 'partner', label: 'Partner', sortable: false },
|
||||
{
|
||||
key: 'balance',
|
||||
label: 'Stanje',
|
||||
sortable: false,
|
||||
align: 'right',
|
||||
class: 'w-40',
|
||||
},
|
||||
{ key: 'due', label: 'Zapadlost', sortable: true, class: 'w-28' },
|
||||
]"
|
||||
:rows="activities.data || []"
|
||||
:meta="{
|
||||
current_page: activities.current_page,
|
||||
per_page: activities.per_page,
|
||||
total: activities.total,
|
||||
last_page: activities.last_page,
|
||||
}"
|
||||
<!-- Data Table -->
|
||||
<DataTable
|
||||
ref="dataTableRef"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:meta="activities"
|
||||
route-name="notifications.unread"
|
||||
page-param-name="unread-page"
|
||||
:only-props="['activities']"
|
||||
:query="{ client: selectedClient || undefined }"
|
||||
:page-size="15"
|
||||
:page-size-options="[10, 15, 25, 50]"
|
||||
:show-pagination="true"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
:enable-row-selection="true"
|
||||
row-key="id"
|
||||
empty-text="Trenutno ni neprikazanih obvestil."
|
||||
@selection:change="handleSelectionChange"
|
||||
>
|
||||
<template #toolbar-extra>
|
||||
<div v-if="selectedRows.length" class="flex items-center gap-2">
|
||||
<div class="text-sm text-gray-700">
|
||||
Izbrano: <span class="font-medium">{{ selectedRows.length }}</span>
|
||||
<!-- Bulk Actions Toolbar -->
|
||||
<template #toolbar-filters>
|
||||
<div v-if="selectedRows.length" class="flex items-center gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Izbrano:
|
||||
<span class="font-medium text-foreground">{{
|
||||
selectedRows.length
|
||||
}}</span>
|
||||
</div>
|
||||
<Dropdown width="48" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
Akcije
|
||||
<svg
|
||||
class="ml-1 h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
@click="markReadBulk"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem @click="markReadBulk">
|
||||
<Check class="h-4 w-4" />
|
||||
Označi kot prebrano
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected()"
|
||||
:indeterminate="isIndeterminate()"
|
||||
@change="toggleSelectAll"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isRowSelected(row.id)"
|
||||
@change="toggleRowSelection(row.id)"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- What Column -->
|
||||
<template #cell-what="{ row }">
|
||||
<div class="font-medium text-gray-800 truncate">
|
||||
<div class="font-medium truncate">
|
||||
<template v-if="row.contract?.uuid">
|
||||
Pogodba:
|
||||
<span class="text-muted-foreground">Pogodba:</span>
|
||||
<Link
|
||||
v-if="row.contract?.client_case?.uuid"
|
||||
:href="
|
||||
@@ -274,27 +264,31 @@ function markReadBulk() {
|
||||
client_case: row.contract.client_case.uuid,
|
||||
})
|
||||
"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="ml-1 text-primary hover:underline"
|
||||
>
|
||||
{{ row.contract?.reference || "—" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.contract?.reference || "—" }}</span>
|
||||
<span v-else class="ml-1">{{ row.contract?.reference || "—" }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
Primer:
|
||||
<span class="text-muted-foreground">Primer:</span>
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
:href="
|
||||
route('clientCase.show', { client_case: row.client_case.uuid })
|
||||
"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="ml-1 text-primary hover:underline"
|
||||
>
|
||||
{{ row.client_case?.person?.full_name || "—" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "—" }}</span>
|
||||
<span v-else class="ml-1">{{
|
||||
row.client_case?.person?.full_name || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Partner Column -->
|
||||
<template #cell-partner="{ row }">
|
||||
<div class="truncate">
|
||||
{{
|
||||
@@ -304,34 +298,51 @@ function markReadBulk() {
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Balance Column -->
|
||||
<template #cell-balance="{ row }">
|
||||
<div class="text-right">
|
||||
<div class="text-right font-medium">
|
||||
<span v-if="row.contract">{{
|
||||
fmtEUR(row.contract?.account?.balance_amount)
|
||||
}}</span>
|
||||
<span v-else>—</span>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Due Date Column -->
|
||||
<template #cell-due="{ row }">
|
||||
{{ fmtDate(row.due_date) }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[12px] text-gray-500 hover:text-gray-700"
|
||||
@click="markRead(row.id)"
|
||||
>
|
||||
Označi kot prebrano
|
||||
</button>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
Trenutno ni neprikazanih obvestil.
|
||||
<div class="text-sm">
|
||||
{{ fmtDate(row.due_date) }}
|
||||
</div>
|
||||
</template>
|
||||
</DataTableServer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<template #cell-actions="{ row }">
|
||||
<TableActions>
|
||||
<ActionMenuItem @click="markRead(row.id)" label="Označi kot prebrano">
|
||||
<BellOff class="mr-2 h-4 w-4" />
|
||||
Označi kot prebrano
|
||||
</ActionMenuItem>
|
||||
</TableActions>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div
|
||||
class="flex h-20 w-20 items-center justify-center rounded-full bg-muted"
|
||||
>
|
||||
<Inbox class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold">Ni neprikazanih obvestil</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Trenutno nimate nobenih neprikazanih obvestil.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -67,7 +67,7 @@ const props = defineProps({
|
||||
completed_mode: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const viewer = reactive({ open: false, src: "", title: "" });
|
||||
const viewer = reactive({ open: false, src: "", title: "", mimeType: "", filename: "" });
|
||||
function openViewer(doc) {
|
||||
const kind = classifyDocument(doc);
|
||||
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
|
||||
@@ -85,6 +85,8 @@ function openViewer(doc) {
|
||||
viewer.open = true;
|
||||
viewer.src = url;
|
||||
viewer.title = doc.original_name || doc.name;
|
||||
viewer.mimeType = doc.mime_type || "";
|
||||
viewer.filename = doc.original_name || doc.name || "";
|
||||
} else {
|
||||
const url =
|
||||
isContractDoc && doc.contract_uuid
|
||||
@@ -102,6 +104,8 @@ function openViewer(doc) {
|
||||
function closeViewer() {
|
||||
viewer.open = false;
|
||||
viewer.src = "";
|
||||
viewer.mimeType = "";
|
||||
viewer.filename = "";
|
||||
}
|
||||
|
||||
function formatAmount(val) {
|
||||
@@ -454,7 +458,7 @@ const clientSummary = computed(() => {
|
||||
:key="a.id"
|
||||
class="bg-gray-50/70 dark:bg-gray-800/50"
|
||||
>
|
||||
<CardHeader class="pb-3">
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<CardTitle class="text-sm font-medium truncate">
|
||||
{{ activityActionLine(a) || "Aktivnost" }}
|
||||
@@ -610,6 +614,8 @@ const clientSummary = computed(() => {
|
||||
:show="viewer.open"
|
||||
:src="viewer.src"
|
||||
:title="viewer.title"
|
||||
:mime-type="viewer.mimeType"
|
||||
:filename="viewer.filename"
|
||||
@close="closeViewer"
|
||||
/>
|
||||
<ActivityDrawer
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/Components/ui/alert-dialog';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { Trash2, AlertTriangle } from 'lucide-vue-next';
|
||||
|
||||
const confirmingUserDeletion = ref(false);
|
||||
const passwordInput = ref(null);
|
||||
@@ -38,65 +38,68 @@ const closeModal = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Delete Account
|
||||
</template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Trash2 class="h-5 w-5 text-destructive" />
|
||||
<CardTitle>Delete Account</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Permanently delete your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<template #description>
|
||||
Permanently delete your account.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
|
||||
<CardContent class="space-y-4">
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div class="flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-foreground">
|
||||
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<DangerButton @click="confirmUserDeletion">
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</div>
|
||||
<Button variant="destructive" @click="confirmUserDeletion">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<!-- Delete Account Confirmation Modal -->
|
||||
<DialogModal :show="confirmingUserDeletion" @close="closeModal">
|
||||
<template #title>
|
||||
Delete Account
|
||||
</template>
|
||||
<!-- Delete Account Confirmation Dialog -->
|
||||
<AlertDialog :open="confirmingUserDeletion" @update:open="closeModal">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Account</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<template #content>
|
||||
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
|
||||
<div class="py-4">
|
||||
<Input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="deleteUser"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="deleteUser"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="form.processing"
|
||||
@click="deleteUser"
|
||||
>
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,141 +1,164 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { ref } from "vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
|
||||
defineProps({
|
||||
sessions: Array,
|
||||
sessions: Array,
|
||||
});
|
||||
|
||||
const confirmingLogout = ref(false);
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
password: '',
|
||||
password: "",
|
||||
});
|
||||
|
||||
const confirmLogout = () => {
|
||||
confirmingLogout.value = true;
|
||||
confirmingLogout.value = true;
|
||||
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
};
|
||||
|
||||
const logoutOtherBrowserSessions = () => {
|
||||
form.delete(route('other-browser-sessions.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.value.focus(),
|
||||
onFinish: () => form.reset(),
|
||||
});
|
||||
form.delete(route("other-browser-sessions.destroy"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.value.focus(),
|
||||
onFinish: () => form.reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
confirmingLogout.value = false;
|
||||
confirmingLogout.value = false;
|
||||
|
||||
form.reset();
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Browser Sessions
|
||||
</template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<LogOut size="18" />
|
||||
<CardTitle>Aktivne prijave</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Upravljanje in izpis aktivnih prijav no drugih brskalnikih in napravah.
|
||||
</CardDescription>
|
||||
</template>
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions && sessions.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(session, i) in sessions"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<Monitor
|
||||
v-if="session.agent.is_desktop"
|
||||
class="h-8 w-8 text-muted-foreground"
|
||||
/>
|
||||
<Smartphone v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<template #description>
|
||||
Manage and log out your active sessions on other browsers and devices.
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ session.agent.platform ? session.agent.platform : "Unknown" }} -
|
||||
{{ session.agent.browser ? session.agent.browser : "Unknown" }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ session.ip_address }}
|
||||
<span
|
||||
v-if="session.is_current_device"
|
||||
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
|
||||
>
|
||||
Ta naprava
|
||||
</span>
|
||||
<span v-else class="ml-1"> · Aktiven {{ session.last_active }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div v-else class="rounded-lg border border-dashed p-8 text-center">
|
||||
<Monitor class="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<p class="text-sm text-muted-foreground">Najdena nobena odprta prijava.</p>
|
||||
</div>
|
||||
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions.length > 0" class="mt-5 space-y-6">
|
||||
<div v-for="(session, i) in sessions" :key="i" class="flex items-center">
|
||||
<div>
|
||||
<svg v-if="session.agent.is_desktop" class="w-8 h-8 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
|
||||
</svg>
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-1 items-center justify-end w-full">
|
||||
<Button @click="confirmLogout">
|
||||
<LogOut class="h-4 w-4 mr-2" />
|
||||
Log Out Other Browser Sessions
|
||||
</Button>
|
||||
|
||||
<svg v-else class="w-8 h-8 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.recentlySuccessful"
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<span>Done.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppCard>
|
||||
|
||||
<div class="ms-3">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ session.agent.platform ? session.agent.platform : 'Unknown' }} - {{ session.agent.browser ? session.agent.browser : 'Unknown' }}
|
||||
</div>
|
||||
<!-- Log Out Other Devices Confirmation Dialog -->
|
||||
<Dialog :open="confirmingLogout" @update:open="closeModal">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log Out Other Browser Sessions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please enter your password to confirm you would like to log out of your other
|
||||
browser sessions across all of your devices.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ session.ip_address }},
|
||||
<div class="py-4">
|
||||
<Input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="logoutOtherBrowserSessions"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<span v-if="session.is_current_device" class="text-green-500 font-semibold">This device</span>
|
||||
<span v-else>Last active {{ session.last_active }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-5">
|
||||
<PrimaryButton @click="confirmLogout">
|
||||
Log Out Other Browser Sessions
|
||||
</PrimaryButton>
|
||||
|
||||
<ActionMessage :on="form.recentlySuccessful" class="ms-3">
|
||||
Done.
|
||||
</ActionMessage>
|
||||
</div>
|
||||
|
||||
<!-- Log Out Other Devices Confirmation Modal -->
|
||||
<DialogModal :show="confirmingLogout" @close="closeModal">
|
||||
<template #title>
|
||||
Log Out Other Browser Sessions
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="logoutOtherBrowserSessions"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="logoutOtherBrowserSessions"
|
||||
>
|
||||
Log Out Other Browser Sessions
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeModal"> Cancel </Button>
|
||||
<Button :disabled="form.processing" @click="logoutOtherBrowserSessions">
|
||||
Log Out Other Browser Sessions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { router, useForm, usePage } from '@inertiajs/vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { router, useForm, usePage } from "@inertiajs/vue3";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import ConfirmsPassword from "@/Components/ConfirmsPassword.vue";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from "lucide-vue-next";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
|
||||
const props = defineProps({
|
||||
requiresConfirmation: Boolean,
|
||||
requiresConfirmation: Boolean,
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
@@ -23,231 +30,298 @@ const setupKey = ref(null);
|
||||
const recoveryCodes = ref([]);
|
||||
|
||||
const confirmationForm = useForm({
|
||||
code: '',
|
||||
code: "",
|
||||
});
|
||||
|
||||
const twoFactorEnabled = computed(
|
||||
() => ! enabling.value && page.props.auth.user?.two_factor_enabled,
|
||||
() => !enabling.value && page.props.auth.user?.two_factor_enabled
|
||||
);
|
||||
|
||||
watch(twoFactorEnabled, () => {
|
||||
if (! twoFactorEnabled.value) {
|
||||
confirmationForm.reset();
|
||||
confirmationForm.clearErrors();
|
||||
}
|
||||
if (!twoFactorEnabled.value) {
|
||||
confirmationForm.reset();
|
||||
confirmationForm.clearErrors();
|
||||
}
|
||||
});
|
||||
|
||||
const enableTwoFactorAuthentication = () => {
|
||||
enabling.value = true;
|
||||
enabling.value = true;
|
||||
|
||||
router.post(route('two-factor.enable'), {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => Promise.all([
|
||||
showQrCode(),
|
||||
showSetupKey(),
|
||||
showRecoveryCodes(),
|
||||
]),
|
||||
onFinish: () => {
|
||||
enabling.value = false;
|
||||
confirming.value = props.requiresConfirmation;
|
||||
},
|
||||
});
|
||||
router.post(
|
||||
route("two-factor.enable"),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
|
||||
onFinish: () => {
|
||||
enabling.value = false;
|
||||
confirming.value = props.requiresConfirmation;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const showQrCode = () => {
|
||||
return axios.get(route('two-factor.qr-code')).then(response => {
|
||||
qrCode.value = response.data.svg;
|
||||
});
|
||||
return axios.get(route("two-factor.qr-code")).then((response) => {
|
||||
qrCode.value = response.data.svg;
|
||||
});
|
||||
};
|
||||
|
||||
const showSetupKey = () => {
|
||||
return axios.get(route('two-factor.secret-key')).then(response => {
|
||||
setupKey.value = response.data.secretKey;
|
||||
});
|
||||
}
|
||||
return axios.get(route("two-factor.secret-key")).then((response) => {
|
||||
setupKey.value = response.data.secretKey;
|
||||
});
|
||||
};
|
||||
|
||||
const showRecoveryCodes = () => {
|
||||
return axios.get(route('two-factor.recovery-codes')).then(response => {
|
||||
recoveryCodes.value = response.data;
|
||||
});
|
||||
return axios.get(route("two-factor.recovery-codes")).then((response) => {
|
||||
recoveryCodes.value = response.data;
|
||||
});
|
||||
};
|
||||
|
||||
const confirmTwoFactorAuthentication = () => {
|
||||
confirmationForm.post(route('two-factor.confirm'), {
|
||||
errorBag: "confirmTwoFactorAuthentication",
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
confirming.value = false;
|
||||
qrCode.value = null;
|
||||
setupKey.value = null;
|
||||
},
|
||||
});
|
||||
confirmationForm.post(route("two-factor.confirm"), {
|
||||
errorBag: "confirmTwoFactorAuthentication",
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
confirming.value = false;
|
||||
qrCode.value = null;
|
||||
setupKey.value = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const regenerateRecoveryCodes = () => {
|
||||
axios
|
||||
.post(route('two-factor.recovery-codes'))
|
||||
.then(() => showRecoveryCodes());
|
||||
axios.post(route("two-factor.recovery-codes")).then(() => showRecoveryCodes());
|
||||
};
|
||||
|
||||
const disableTwoFactorAuthentication = () => {
|
||||
disabling.value = true;
|
||||
disabling.value = true;
|
||||
|
||||
router.delete(route('two-factor.disable'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
disabling.value = false;
|
||||
confirming.value = false;
|
||||
},
|
||||
});
|
||||
router.delete(route("two-factor.disable"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
disabling.value = false;
|
||||
confirming.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Two Factor Authentication
|
||||
</template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class="p-4 border-t"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield size="18" />
|
||||
<CardTitle>Dvonivojska overitev</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Dodatna varnost za vaš račun z dvonivojsko overitvijo.
|
||||
</CardDescription>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Add additional security to your account using two factor authentication.
|
||||
</template>
|
||||
<!-- Status Header -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
v-if="twoFactorEnabled && !confirming"
|
||||
class="text-lg font-semibold flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle class="h-5 w-5 text-green-600" />
|
||||
Dvonivojska overitev omogočena
|
||||
</h3>
|
||||
<h3
|
||||
v-else-if="twoFactorEnabled && confirming"
|
||||
class="text-lg font-semibold flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle class="h-5 w-5 text-amber-600" />
|
||||
|
||||
<template #content>
|
||||
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-medium text-gray-900">
|
||||
You have enabled two factor authentication.
|
||||
</h3>
|
||||
Dokončaj namestitev dvonivojske overitve
|
||||
</h3>
|
||||
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
|
||||
Dvonivojska overitev onemogočena
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-medium text-gray-900">
|
||||
Finish enabling two factor authentication.
|
||||
</h3>
|
||||
<!-- QR Code & Setup -->
|
||||
<div v-if="twoFactorEnabled" class="space-y-6">
|
||||
<div v-if="qrCode" class="space-y-4">
|
||||
<div class="rounded-lg border bg-muted/50 p-4">
|
||||
<p v-if="confirming" class="text-sm font-medium mb-4">
|
||||
Za dokončanje omogočanja dvostopenjske overitve skenirajte naslednjo QR-kodo z
|
||||
aplikacijo za preverjanje pristnosti na vašem telefonu ali vnesite
|
||||
namestitveno kodo in vpišite ustvarjeno OTP-kodo.
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground mb-4">
|
||||
Dvonivojska overitev je zdaj omogočena. Skenirajte QR kodo z aplikacijo za
|
||||
preverjanje pristnosti na vašem telefonu ali vnesite namestitveni ključ.
|
||||
</p>
|
||||
|
||||
<h3 v-else class="text-lg font-medium text-gray-900">
|
||||
You have not enabled two factor authentication.
|
||||
</h3>
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
|
||||
|
||||
<div class="mt-3 max-w-xl text-sm text-gray-600">
|
||||
<p>
|
||||
When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.
|
||||
</p>
|
||||
<!-- Setup Key -->
|
||||
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<Label class="text-xs text-muted-foreground">Namestitveni Ključ</Label>
|
||||
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(setupKey)"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="twoFactorEnabled">
|
||||
<div v-if="qrCode">
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p v-if="confirming" class="font-semibold">
|
||||
To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.
|
||||
</p>
|
||||
<!-- Confirmation Code Input -->
|
||||
<div v-if="confirming" class="space-y-2">
|
||||
<Label for="code">Potrdite kodo</Label>
|
||||
<Input
|
||||
id="code"
|
||||
v-model="confirmationForm.code"
|
||||
type="text"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
placeholder="Enter 6-digit code"
|
||||
class="max-w-xs"
|
||||
@keyup.enter="confirmTwoFactorAuthentication"
|
||||
/>
|
||||
<InputError :message="confirmationForm.errors.code" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else>
|
||||
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Recovery Codes -->
|
||||
<div v-if="recoveryCodes.length > 0 && !confirming" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Key
|
||||
class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Shranite to obnovitveno kodo v upravitelja gesel. Lahko se uporabi za obnovo
|
||||
vstopa v vaš račun, če se izgubi naprava z dvostopenjskim overjanjem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-2 inline-block bg-white" v-html="qrCode" />
|
||||
|
||||
<div v-if="setupKey" class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p class="font-semibold">
|
||||
Setup Key: <span v-html="setupKey"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="confirming" class="mt-4">
|
||||
<InputLabel for="code" value="Code" />
|
||||
|
||||
<TextInput
|
||||
id="code"
|
||||
v-model="confirmationForm.code"
|
||||
type="text"
|
||||
name="code"
|
||||
class="block mt-1 w-1/2"
|
||||
inputmode="numeric"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
@keyup.enter="confirmTwoFactorAuthentication"
|
||||
/>
|
||||
|
||||
<InputError :message="confirmationForm.errors.code" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recoveryCodes.length > 0 && ! confirming">
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p class="font-semibold">
|
||||
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
|
||||
<div v-for="code in recoveryCodes" :key="code">
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted p-4">
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
<div
|
||||
v-for="code in recoveryCodes"
|
||||
:key="code"
|
||||
class="flex items-center justify-between p-2 bg-background rounded border"
|
||||
>
|
||||
<span>{{ code }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
@click="copyToClipboard(code)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-row gap-2 items-center justify-end w-full">
|
||||
<!-- Enable -->
|
||||
<div v-if="!twoFactorEnabled">
|
||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
||||
<Button type="button" :disabled="enabling">
|
||||
<Shield class="h-4 w-4 mr-2" />
|
||||
Enable
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div v-if="! twoFactorEnabled">
|
||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
||||
<PrimaryButton type="button" :class="{ 'opacity-25': enabling }" :disabled="enabling">
|
||||
Enable
|
||||
</PrimaryButton>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
<!-- Confirm -->
|
||||
<template v-else>
|
||||
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
||||
<Button v-if="confirming" type="button" :disabled="enabling">
|
||||
<CheckCircle class="h-4 w-4 mr-2" />
|
||||
Confirm
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<div v-else>
|
||||
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
||||
<PrimaryButton
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
class="me-3"
|
||||
:class="{ 'opacity-25': enabling }"
|
||||
:disabled="enabling"
|
||||
>
|
||||
Confirm
|
||||
</PrimaryButton>
|
||||
</ConfirmsPassword>
|
||||
<!-- Regenerate Recovery Codes -->
|
||||
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
||||
<Button
|
||||
v-if="recoveryCodes.length > 0 && !confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Regenerate Recovery Codes
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
||||
<SecondaryButton
|
||||
v-if="recoveryCodes.length > 0 && ! confirming"
|
||||
class="me-3"
|
||||
>
|
||||
Regenerate Recovery Codes
|
||||
</SecondaryButton>
|
||||
</ConfirmsPassword>
|
||||
<!-- Show Recovery Codes -->
|
||||
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
||||
<Button
|
||||
v-if="recoveryCodes.length === 0 && !confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Show Recovery Codes
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
||||
<SecondaryButton
|
||||
v-if="recoveryCodes.length === 0 && ! confirming"
|
||||
class="me-3"
|
||||
>
|
||||
Show Recovery Codes
|
||||
</SecondaryButton>
|
||||
</ConfirmsPassword>
|
||||
<!-- Cancel/Disable -->
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<SecondaryButton
|
||||
v-if="confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<DangerButton
|
||||
v-if="! confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Disable
|
||||
</DangerButton>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="!confirming"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</div>
|
||||
</template>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
||||
@@ -1,100 +1,106 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { ref } from "vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import { CheckCircle, Lock } from "lucide-vue-next";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { CardTitle } from "@/Components/ui/card";
|
||||
|
||||
const passwordInput = ref(null);
|
||||
const currentPasswordInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
current_password: "",
|
||||
password: "",
|
||||
password_confirmation: "",
|
||||
});
|
||||
|
||||
const updatePassword = () => {
|
||||
form.put(route('user-password.update'), {
|
||||
errorBag: 'updatePassword',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
onError: () => {
|
||||
if (form.errors.password) {
|
||||
form.reset('password', 'password_confirmation');
|
||||
passwordInput.value.focus();
|
||||
}
|
||||
form.put(route("user-password.update"), {
|
||||
errorBag: "updatePassword",
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
onError: () => {
|
||||
if (form.errors.password) {
|
||||
form.reset("password", "password_confirmation");
|
||||
passwordInput.value.focus();
|
||||
}
|
||||
|
||||
if (form.errors.current_password) {
|
||||
form.reset('current_password');
|
||||
currentPasswordInput.value.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
if (form.errors.current_password) {
|
||||
form.reset("current_password");
|
||||
currentPasswordInput.value.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updatePassword">
|
||||
<template #title>
|
||||
Update Password
|
||||
</template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class="p-4 border-t"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Lock size="18" />
|
||||
<CardTitle>Posodobi geslo</CardTitle>
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
Poskrbite, da vaš račun uporablja dolgo, naključno geslo za varnost.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Ensure your account is using a long, random password to stay secure.
|
||||
</template>
|
||||
<form @submit.prevent="updatePassword" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="current_password">Trenutno geslo</Label>
|
||||
<Input
|
||||
id="current_password"
|
||||
ref="currentPasswordInput"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<InputError :message="form.errors.current_password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="current_password" value="Current Password" />
|
||||
<TextInput
|
||||
id="current_password"
|
||||
ref="currentPasswordInput"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<InputError :message="form.errors.current_password" class="mt-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Novo geslo</Label>
|
||||
<Input
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="password" value="New Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password_confirmation">Potrdi geslo</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="password_confirmation" value="Confirm Password" />
|
||||
<TextInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
|
||||
<span v-if="form.recentlySuccessful">Shranjeno.</span>
|
||||
</div>
|
||||
<Button type="submit" :disabled="form.processing"> Shrani </Button>
|
||||
</div>
|
||||
</template>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Link, router, useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { ref } from "vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/Components/ui/avatar";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from "lucide-vue-next";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { CardTitle } from "@/Components/ui/card";
|
||||
|
||||
const props = defineProps({
|
||||
user: Object,
|
||||
user: Object,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
_method: 'PUT',
|
||||
name: props.user.name,
|
||||
email: props.user.email,
|
||||
photo: null,
|
||||
_method: "PUT",
|
||||
name: props.user.name,
|
||||
email: props.user.email,
|
||||
photo: null,
|
||||
});
|
||||
|
||||
const verificationLinkSent = ref(null);
|
||||
@@ -25,166 +26,188 @@ const photoPreview = ref(null);
|
||||
const photoInput = ref(null);
|
||||
|
||||
const updateProfileInformation = () => {
|
||||
if (photoInput.value) {
|
||||
form.photo = photoInput.value.files[0];
|
||||
}
|
||||
if (photoInput.value) {
|
||||
form.photo = photoInput.value.files[0];
|
||||
}
|
||||
|
||||
form.post(route('user-profile-information.update'), {
|
||||
errorBag: 'updateProfileInformation',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => clearPhotoFileInput(),
|
||||
});
|
||||
form.post(route("user-profile-information.update"), {
|
||||
errorBag: "updateProfileInformation",
|
||||
preserveScroll: true,
|
||||
onSuccess: () => clearPhotoFileInput(),
|
||||
});
|
||||
};
|
||||
|
||||
const sendEmailVerification = () => {
|
||||
verificationLinkSent.value = true;
|
||||
verificationLinkSent.value = true;
|
||||
};
|
||||
|
||||
const selectNewPhoto = () => {
|
||||
photoInput.value.click();
|
||||
photoInput.value.click();
|
||||
};
|
||||
|
||||
const updatePhotoPreview = () => {
|
||||
const photo = photoInput.value.files[0];
|
||||
const photo = photoInput.value.files[0];
|
||||
|
||||
if (! photo) return;
|
||||
if (!photo) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
photoPreview.value = e.target.result;
|
||||
};
|
||||
reader.onload = (e) => {
|
||||
photoPreview.value = e.target.result;
|
||||
};
|
||||
|
||||
reader.readAsDataURL(photo);
|
||||
reader.readAsDataURL(photo);
|
||||
};
|
||||
|
||||
const deletePhoto = () => {
|
||||
router.delete(route('current-user-photo.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
photoPreview.value = null;
|
||||
clearPhotoFileInput();
|
||||
},
|
||||
});
|
||||
router.delete(route("current-user-photo.destroy"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
photoPreview.value = null;
|
||||
clearPhotoFileInput();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const clearPhotoFileInput = () => {
|
||||
if (photoInput.value?.value) {
|
||||
photoInput.value.value = null;
|
||||
}
|
||||
if (photoInput.value?.value) {
|
||||
photoInput.value.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updateProfileInformation">
|
||||
<template #title>
|
||||
Profile Information
|
||||
</template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class="p-4 border-t"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<User size="18" />
|
||||
<CardTitle>Informacije profila</CardTitle>
|
||||
</div>
|
||||
<p class="text-sm">Posodobite informacije vašega profila in e-poštni naslov.</p>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Update your account's profile information and email address.
|
||||
</template>
|
||||
<form @submit.prevent="updateProfileInformation" class="space-y-6">
|
||||
<!-- Profile Photo -->
|
||||
<div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4">
|
||||
<input
|
||||
id="photo"
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="updatePhotoPreview"
|
||||
/>
|
||||
|
||||
<template #form>
|
||||
<!-- Profile Photo -->
|
||||
<div v-if="$page.props.jetstream.managesProfilePhotos" class="col-span-6 sm:col-span-4">
|
||||
<!-- Profile Photo File Input -->
|
||||
<input
|
||||
id="photo"
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="updatePhotoPreview"
|
||||
<Label for="photo">Fotografija</Label>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current/Preview Photo -->
|
||||
<Avatar class="h-20 w-20">
|
||||
<AvatarImage v-if="photoPreview" :src="photoPreview" :alt="user.name" />
|
||||
<AvatarImage v-else :src="user.profile_photo_url" :alt="user.name" />
|
||||
<AvatarFallback>
|
||||
<User class="h-8 w-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click.prevent="selectNewPhoto"
|
||||
>
|
||||
<Camera class="h-4 w-4 mr-2" />
|
||||
Izberi fotografijo
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="user.profile_photo_path"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click.prevent="deletePhoto"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Odstrani
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputError :message="form.errors.photo" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Ime</Label>
|
||||
<Input id="name" v-model="form.name" type="text" required autocomplete="name" />
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">E-pošta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
<InputError :message="form.errors.email" class="mt-2" />
|
||||
|
||||
<!-- Email Verification -->
|
||||
<div
|
||||
v-if="
|
||||
$page.props.jetstream.hasEmailVerification && user.email_verified_at === null
|
||||
"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<div class="flex-1 text-sm">
|
||||
<p class="text-amber-800 dark:text-amber-200">
|
||||
Vaš e-poštni naslov ni potrjen.
|
||||
<Link
|
||||
:href="route('verification.send')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium"
|
||||
@click.prevent="sendEmailVerification"
|
||||
>
|
||||
|
||||
<InputLabel for="photo" value="Photo" />
|
||||
|
||||
<!-- Current Profile Photo -->
|
||||
<div v-show="! photoPreview" class="mt-2">
|
||||
<img :src="user.profile_photo_url" :alt="user.name" class="rounded-full h-20 w-20 object-cover">
|
||||
</div>
|
||||
|
||||
<!-- New Profile Photo Preview -->
|
||||
<div v-show="photoPreview" class="mt-2">
|
||||
<span
|
||||
class="block rounded-full w-20 h-20 bg-cover bg-no-repeat bg-center"
|
||||
:style="'background-image: url(\'' + photoPreview + '\');'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SecondaryButton class="mt-2 me-2" type="button" @click.prevent="selectNewPhoto">
|
||||
Select A New Photo
|
||||
</SecondaryButton>
|
||||
|
||||
<SecondaryButton
|
||||
v-if="user.profile_photo_path"
|
||||
type="button"
|
||||
class="mt-2"
|
||||
@click.prevent="deletePhoto"
|
||||
Kliknite tukaj za ponovno pošiljanje potrditvenega e-sporočila.
|
||||
</Link>
|
||||
</p>
|
||||
<div
|
||||
v-show="verificationLinkSent"
|
||||
class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4" />
|
||||
<span
|
||||
>Nova povezava za potrditev je bila poslana na vaš e-poštni
|
||||
naslov.</span
|
||||
>
|
||||
Remove Photo
|
||||
</SecondaryButton>
|
||||
|
||||
<InputError :message="form.errors.photo" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
<InputError :message="form.errors.email" class="mt-2" />
|
||||
|
||||
<div v-if="$page.props.jetstream.hasEmailVerification && user.email_verified_at === null">
|
||||
<p class="text-sm mt-2">
|
||||
Your email address is unverified.
|
||||
|
||||
<Link
|
||||
:href="route('verification.send')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
@click.prevent="sendEmailVerification"
|
||||
>
|
||||
Click here to re-send the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div v-show="verificationLinkSent" class="mt-2 font-medium text-sm text-green-600">
|
||||
A new verification link has been sent to your email address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
|
||||
<span v-if="form.recentlySuccessful">Shranjeno.</span>
|
||||
</div>
|
||||
<Button type="submit" :disabled="form.processing"> Shrani </Button>
|
||||
</div>
|
||||
</template>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
||||
import LogoutOtherBrowserSessionsForm from '@/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import TwoFactorAuthenticationForm from '@/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue';
|
||||
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
||||
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
||||
@@ -26,13 +26,13 @@ defineProps({
|
||||
<div v-if="$page.props.jetstream.canUpdateProfileInformation">
|
||||
<UpdateProfileInformationForm :user="$page.props.auth.user" />
|
||||
|
||||
<SectionBorder />
|
||||
<Separator class="my-10" />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.canUpdatePassword">
|
||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||
|
||||
<SectionBorder />
|
||||
<Separator class="my-10" />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.canManageTwoFactorAuthentication">
|
||||
@@ -41,7 +41,7 @@ defineProps({
|
||||
class="mt-10 sm:mt-0"
|
||||
/>
|
||||
|
||||
<SectionBorder />
|
||||
<Separator class="my-10" />
|
||||
</div>
|
||||
|
||||
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user