Compare commits
29 Commits
| 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 |
@@ -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
|
||||
+11
-1
@@ -25,7 +25,17 @@ yarn-error.log
|
||||
check-*.php
|
||||
test-*.php
|
||||
fix-*.php
|
||||
clean-*.php
|
||||
mark-*.php
|
||||
|
||||
# Development Documentation
|
||||
IMPORT_*.md
|
||||
V2_*.md
|
||||
V2_*.md
|
||||
REPORTS_*.md
|
||||
DEDUPLICATION_*.md
|
||||
|
||||
# Docker Local Testing
|
||||
docker-compose.local.yaml
|
||||
docker-compose.override.yaml
|
||||
.env.local
|
||||
.env.docker
|
||||
+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.
|
||||
@@ -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,11 @@ 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,
|
||||
@@ -48,6 +51,7 @@ public function create(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
|
||||
@@ -319,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
@@ -330,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
@@ -348,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'))) {
|
||||
@@ -397,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->get();
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$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,
|
||||
@@ -421,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,
|
||||
@@ -438,7 +452,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,6 +1079,156 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive multiple contracts in a batch operation
|
||||
*/
|
||||
public function archiveBatch(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contracts' => 'required|array',
|
||||
'contracts.*' => 'required|uuid|exists:contracts,uuid',
|
||||
'reactivate' => 'boolean',
|
||||
]);
|
||||
|
||||
$reactivate = $validated['reactivate'] ?? false;
|
||||
|
||||
// Get archive setting
|
||||
$setting = \App\Models\ArchiveSetting::query()
|
||||
->where('enabled', true)
|
||||
->whereIn('strategy', ['immediate', 'manual'])
|
||||
->where('reactivate', $reactivate)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $setting) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
}
|
||||
|
||||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||
$successCount = 0;
|
||||
$skippedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['contracts'] as $contractUuid) {
|
||||
try {
|
||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (!$contract->active) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientCase = $contract->clientCase;
|
||||
|
||||
$context = [
|
||||
'contract_id' => $contract->id,
|
||||
'client_case_id' => $clientCase->id,
|
||||
'account_id' => $contract->account->id ?? null,
|
||||
];
|
||||
|
||||
// Execute archive setting
|
||||
$executor->executeSetting($setting, $context, \Auth::id());
|
||||
|
||||
// Transaction for segment updates and activity logging
|
||||
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||
// Create activity log
|
||||
if ($setting->action_id && $setting->decision_id) {
|
||||
$activityData = [
|
||||
'client_case_id' => $clientCase->id,
|
||||
'action_id' => $setting->action_id,
|
||||
'decision_id' => $setting->decision_id,
|
||||
'note' => ($reactivate)
|
||||
? "Ponovno aktivirana pogodba $contract->reference"
|
||||
: "Arhivirana pogodba $contract->reference",
|
||||
];
|
||||
|
||||
try {
|
||||
\App\Models\Activity::create($activityData);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Activity could not be created during batch archive');
|
||||
}
|
||||
}
|
||||
|
||||
// Move to archive segment if specified
|
||||
if ($setting->segment_id) {
|
||||
$segmentId = $setting->segment_id;
|
||||
|
||||
// Deactivate all current segments
|
||||
$contract->segments()
|
||||
->allRelatedIds()
|
||||
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
|
||||
'active' => false,
|
||||
'updated_at' => now(),
|
||||
]));
|
||||
|
||||
// Activate archive segment
|
||||
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||
'active' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$contract->segments()->attach($segmentId, [
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel pending field jobs
|
||||
$contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->update([
|
||||
'cancelled_at' => date('Y-m-d'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$successCount++;
|
||||
} catch (Exception $e) {
|
||||
\Log::error('Error archiving contract in batch', [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors[] = [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
$message = "Archived $successCount contracts";
|
||||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
'details' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $reactivate
|
||||
? "Successfully reactivated $successCount contracts"
|
||||
: "Successfully archived $successCount contracts";
|
||||
|
||||
if ($skippedCount > 0) {
|
||||
$message .= " ($skippedCount already archived)";
|
||||
}
|
||||
|
||||
return back()->with('flash', [
|
||||
'success' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
|
||||
*/
|
||||
|
||||
@@ -27,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) {
|
||||
@@ -51,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']),
|
||||
]);
|
||||
|
||||
@@ -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', [
|
||||
@@ -197,12 +197,12 @@ public function process(Import $import, Request $request, ImportServiceV2 $proce
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
|
||||
$import->update(['status' => 'failed']);
|
||||
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,10 @@ 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)) {
|
||||
@@ -1688,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);
|
||||
@@ -1698,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (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;
|
||||
@@ -2974,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];
|
||||
}
|
||||
@@ -2987,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();
|
||||
@@ -3163,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 = [];
|
||||
@@ -3211,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>
|
||||
|
||||
@@ -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="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="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="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>
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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,
|
||||
@@ -39,6 +40,9 @@ import {
|
||||
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: () => [] },
|
||||
@@ -123,13 +127,19 @@ const contracts = ref({
|
||||
const segmentId = ref(null);
|
||||
const search = ref("");
|
||||
const clientId = ref(null);
|
||||
const startDateFrom = ref("");
|
||||
const startDateTo = ref("");
|
||||
const promiseDateFrom = ref("");
|
||||
const promiseDateTo = ref("");
|
||||
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);
|
||||
|
||||
@@ -153,6 +163,11 @@ const contractColumns = [
|
||||
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" },
|
||||
];
|
||||
|
||||
@@ -175,19 +190,22 @@ async function loadContracts(url = null) {
|
||||
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 (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 res = await fetch(target, {
|
||||
const { data: json } = await axios.get(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
// Wait for next tick before updating to avoid Vue reconciliation issues
|
||||
await nextTick();
|
||||
@@ -238,10 +256,13 @@ function goToPage(page) {
|
||||
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 (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);
|
||||
@@ -255,10 +276,8 @@ function resetFilters() {
|
||||
segmentId.value = null;
|
||||
clientId.value = null;
|
||||
search.value = "";
|
||||
startDateFrom.value = "";
|
||||
startDateTo.value = "";
|
||||
promiseDateFrom.value = "";
|
||||
promiseDateTo.value = "";
|
||||
startDateRange.value = { start: null, end: null };
|
||||
promiseDateRange.value = { start: null, end: null };
|
||||
onlyMobile.value = false;
|
||||
onlyValidated.value = false;
|
||||
contracts.value = {
|
||||
@@ -448,9 +467,10 @@ const numbersCount = computed(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="form.delivery_report"
|
||||
@update:checked="(val) => (form.delivery_report = val)"
|
||||
: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
|
||||
@@ -553,17 +573,15 @@ const numbersCount = computed(() => {
|
||||
</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>
|
||||
<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>
|
||||
@@ -586,29 +604,21 @@ const numbersCount = computed(() => {
|
||||
<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>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Od</Label>
|
||||
<Input v-model="startDateFrom" type="date" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Do</Label>
|
||||
<Input v-model="startDateTo" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Od</Label>
|
||||
<Input v-model="promiseDateFrom" type="date" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Do</Label>
|
||||
<Input v-model="promiseDateTo" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<AppRangeDatePicker
|
||||
v-model="promiseDateRange"
|
||||
placeholder="Izberi obdobje"
|
||||
button-class="w-full"
|
||||
:number-of-months="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,8 +631,8 @@ const numbersCount = computed(() => {
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyMobile"
|
||||
@update:checked="
|
||||
:model-value="onlyMobile"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
onlyMobile = val;
|
||||
}
|
||||
@@ -635,8 +645,8 @@ const numbersCount = computed(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyValidated"
|
||||
@update:checked="
|
||||
:model-value="onlyValidated"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
onlyValidated = val;
|
||||
}
|
||||
@@ -653,11 +663,11 @@ const numbersCount = computed(() => {
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="loadContracts()">
|
||||
<SearchIcon class="h-4 w-4 mr-2" />
|
||||
<SearchIcon class="h-4 w-4" />
|
||||
Išči pogodbe
|
||||
</Button>
|
||||
<Button @click="resetFilters" variant="outline">
|
||||
<XCircleIcon class="h-4 w-4 mr-2" />
|
||||
<XCircleIcon class="h-4 w-4" />
|
||||
Počisti filtre
|
||||
</Button>
|
||||
</div>
|
||||
@@ -669,7 +679,7 @@ const numbersCount = computed(() => {
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Rezultati iskanja</CardTitle>
|
||||
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
|
||||
<CardDescription v-if="contracts.meta.total > 0">
|
||||
Najdeno {{ contracts.meta.total }}
|
||||
{{
|
||||
@@ -689,7 +699,7 @@ const numbersCount = computed(() => {
|
||||
variant="secondary"
|
||||
class="text-sm"
|
||||
>
|
||||
<CheckCircle2Icon class="h-3 w-3 mr-1" />
|
||||
<CheckCircle2Icon class="h-3 w-3" />
|
||||
Izbrano: {{ selectedContractIds.size }}
|
||||
</Badge>
|
||||
<Button
|
||||
@@ -702,7 +712,7 @@ const numbersCount = computed(() => {
|
||||
@click="submitCreateFromContracts"
|
||||
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
||||
>
|
||||
<SaveIcon class="h-4 w-4 mr-2" />
|
||||
<SaveIcon class="h-4 w-4" />
|
||||
Ustvari paket ({{ selectedContractIds.size }}
|
||||
{{
|
||||
selectedContractIds.size === 1
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
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 },
|
||||
@@ -29,7 +30,6 @@ 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" },
|
||||
@@ -84,7 +84,7 @@ function confirmDelete() {
|
||||
</div>
|
||||
<Link :href="route('admin.packages.create')">
|
||||
<Button>
|
||||
<PlusIcon class="h-4 w-4 mr-2" />
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
Nov paket
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -111,10 +111,6 @@ function confirmDelete() {
|
||||
: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>
|
||||
@@ -128,7 +124,9 @@ function confirmDelete() {
|
||||
</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 }">
|
||||
@@ -157,8 +155,10 @@ function confirmDelete() {
|
||||
<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.
|
||||
<strong v-if="packageToDelete"
|
||||
>#{{ packageToDelete.id }} -
|
||||
{{ packageToDelete.name || "Brez imena" }}</strong
|
||||
>? Tega dejanja ni mogoče razveljaviti.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -120,10 +120,11 @@ const store = async () => {
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const contractUuids = Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
|
||||
? form.contract_uuids
|
||||
: null;
|
||||
|
||||
const contractUuids =
|
||||
Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
|
||||
? form.contract_uuids
|
||||
: null;
|
||||
|
||||
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
||||
|
||||
form
|
||||
@@ -175,37 +176,43 @@ const autoMailRequiresContract = computed(() => {
|
||||
});
|
||||
|
||||
const contractItems = computed(() => {
|
||||
return pageContracts.value.map(c => ({
|
||||
return pageContracts.value.map((c) => ({
|
||||
value: c.uuid,
|
||||
label: `${c.reference}${c.name ? ` - ${c.name}` : ''}`
|
||||
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)) {
|
||||
if (
|
||||
autoMailRequiresContract.value &&
|
||||
(!form.contract_uuids || form.contract_uuids.length === 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const autoMailDisabledHint = computed(() => {
|
||||
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)) {
|
||||
|
||||
if (
|
||||
autoMailRequiresContract.value &&
|
||||
(!form.contract_uuids || form.contract_uuids.length === 0)
|
||||
) {
|
||||
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
|
||||
}
|
||||
|
||||
|
||||
return "";
|
||||
});
|
||||
watch(
|
||||
@@ -333,133 +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>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>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="activityNote">Opomba</Label>
|
||||
<Textarea
|
||||
id="activityNote"
|
||||
v-model="form.note"
|
||||
class="block w-full"
|
||||
placeholder="Opomba"
|
||||
/>
|
||||
</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="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="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="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="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>
|
||||
<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_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 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
|
||||
@@ -467,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>
|
||||
|
||||
@@ -24,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, FileDown } 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,
|
||||
});
|
||||
|
||||
@@ -59,10 +72,20 @@ 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 exportColumns = ref([
|
||||
"reference",
|
||||
"customer",
|
||||
"address",
|
||||
"start",
|
||||
"segment",
|
||||
"balance",
|
||||
]);
|
||||
const exportError = ref("");
|
||||
const isExporting = ref(false);
|
||||
|
||||
@@ -85,6 +108,12 @@ const allColumnsSelected = computed(
|
||||
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;
|
||||
@@ -288,6 +317,24 @@ function extractFilenameFromHeaders(headers) {
|
||||
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>
|
||||
@@ -357,6 +404,7 @@ function extractFilenameFromHeaders(headers) {
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
ref="contractTable"
|
||||
:columns="[
|
||||
{ key: 'reference', label: 'Referenca', sortable: false },
|
||||
{ key: 'customer', label: 'Stranka', sortable: false },
|
||||
@@ -380,11 +428,13 @@ function extractFilenameFromHeaders(headers) {
|
||||
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>
|
||||
<template #toolbar-filters="{ table }">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
@@ -481,6 +531,32 @@ function extractFilenameFromHeaders(headers) {
|
||||
<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 }">
|
||||
@@ -519,7 +595,7 @@ function extractFilenameFromHeaders(headers) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Excel export dialog -->
|
||||
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
|
||||
<template #title>
|
||||
<div class="space-y-1">
|
||||
@@ -626,5 +702,15 @@ function extractFilenameFromHeaders(headers) {
|
||||
</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>
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} 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,
|
||||
@@ -55,60 +56,64 @@ const closeModal = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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 class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Browser Sessions</CardTitle>
|
||||
<LogOut size="18" />
|
||||
<CardTitle>Aktivne prijave</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage and log out your active sessions on other browsers and devices.
|
||||
Upravljanje in izpis aktivnih prijav no drugih brskalnikih in napravah.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</template>
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions && sessions.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(session, i) in sessions"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<Monitor
|
||||
v-if="session.agent.is_desktop"
|
||||
class="h-8 w-8 text-muted-foreground"
|
||||
/>
|
||||
<Smartphone v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(session, i) in sessions"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<Monitor
|
||||
v-if="session.agent.is_desktop"
|
||||
class="h-8 w-8 text-muted-foreground"
|
||||
/>
|
||||
<Smartphone v-else class="h-8 w-8 text-muted-foreground" />
|
||||
<div 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="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"
|
||||
>
|
||||
This device
|
||||
</span>
|
||||
<span v-else class="ml-1"> · Last active {{ session.last_active }} </span>
|
||||
</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>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Empty State -->
|
||||
<div v-else class="rounded-lg border border-dashed p-8 text-center">
|
||||
<Monitor class="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<p class="text-sm text-muted-foreground">Najdena nobena odprta prijava.</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-1 items-center justify-end w-full">
|
||||
<Button @click="confirmLogout">
|
||||
<LogOut class="h-4 w-4 mr-2" />
|
||||
Log Out Other Browser Sessions
|
||||
@@ -122,38 +127,38 @@ const closeModal = () => {
|
||||
<span>Done.</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</template>
|
||||
</AppCard>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 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>
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeModal"> Cancel </Button>
|
||||
<Button :disabled="form.processing" @click="logoutOtherBrowserSessions">
|
||||
Log Out Other Browser Sessions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
<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 { 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 { 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,284 +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);
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Two Factor Authentication</CardTitle>
|
||||
<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>
|
||||
|
||||
<!-- Status Header -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
v-if="twoFactorEnabled && !confirming"
|
||||
class="text-lg font-semibold flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle class="h-5 w-5 text-green-600" />
|
||||
Dvonivojska overitev omogočena
|
||||
</h3>
|
||||
<h3
|
||||
v-else-if="twoFactorEnabled && confirming"
|
||||
class="text-lg font-semibold flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle class="h-5 w-5 text-amber-600" />
|
||||
|
||||
Dokončaj namestitev dvonivojske overitve
|
||||
</h3>
|
||||
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
|
||||
Dvonivojska overitev onemogočena
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code & Setup -->
|
||||
<div v-if="twoFactorEnabled" class="space-y-6">
|
||||
<div v-if="qrCode" class="space-y-4">
|
||||
<div class="rounded-lg border bg-muted/50 p-4">
|
||||
<p v-if="confirming" class="text-sm font-medium mb-4">
|
||||
Za dokončanje omogočanja dvostopenjske overitve skenirajte naslednjo QR-kodo z
|
||||
aplikacijo za preverjanje pristnosti na vašem telefonu ali vnesite
|
||||
namestitveno kodo in vpišite ustvarjeno OTP-kodo.
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground mb-4">
|
||||
Dvonivojska overitev je zdaj omogočena. Skenirajte QR kodo z aplikacijo za
|
||||
preverjanje pristnosti na vašem telefonu ali vnesite namestitveni ključ.
|
||||
</p>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
|
||||
|
||||
<!-- Setup Key -->
|
||||
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<Label class="text-xs text-muted-foreground">Namestitveni Ključ</Label>
|
||||
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(setupKey)"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Add additional security to your account using two factor authentication.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<!-- 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" />
|
||||
Two factor authentication is enabled
|
||||
</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" />
|
||||
Finish enabling two factor authentication
|
||||
</h3>
|
||||
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield class="h-5 w-5 text-muted-foreground" />
|
||||
Two factor authentication is disabled
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Recovery Codes -->
|
||||
<div v-if="recoveryCodes.length > 0 && !confirming" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Key
|
||||
class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Shranite to obnovitveno kodo v upravitelja gesel. Lahko se uporabi za obnovo
|
||||
vstopa v vaš račun, če se izgubi naprava z dvostopenjskim overjanjem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-muted p-4">
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
<div
|
||||
v-for="code in recoveryCodes"
|
||||
:key="code"
|
||||
class="flex items-center justify-between p-2 bg-background rounded border"
|
||||
>
|
||||
<span>{{ code }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
@click="copyToClipboard(code)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-row gap-2 items-center justify-end w-full">
|
||||
<!-- Enable -->
|
||||
<div v-if="!twoFactorEnabled">
|
||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
||||
<Button type="button" :disabled="enabling">
|
||||
<Shield class="h-4 w-4 mr-2" />
|
||||
Enable
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
|
||||
<!-- QR Code & Setup -->
|
||||
<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">
|
||||
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>
|
||||
<p v-else class="text-sm text-muted-foreground mb-4">
|
||||
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
|
||||
</p>
|
||||
<!-- 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>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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">Setup Key</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Confirmation Code Input -->
|
||||
<div v-if="confirming" class="space-y-2">
|
||||
<Label for="code">Confirmation Code</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>
|
||||
<!-- Cancel/Disable -->
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- 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">
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Enable -->
|
||||
<div v-if="! twoFactorEnabled">
|
||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
||||
<Button type="button" :disabled="enabling">
|
||||
<Shield class="h-4 w-4 mr-2" />
|
||||
Enable
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
|
||||
<!-- Confirm -->
|
||||
<template v-else>
|
||||
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
:disabled="enabling"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4 mr-2" />
|
||||
Confirm
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- Regenerate Recovery Codes -->
|
||||
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
||||
<Button
|
||||
v-if="recoveryCodes.length > 0 && ! confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Regenerate Recovery Codes
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- Show Recovery Codes -->
|
||||
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
||||
<Button
|
||||
v-if="recoveryCodes.length === 0 && ! confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Show Recovery Codes
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- Cancel/Disable -->
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="! confirming"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</template>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<Button
|
||||
v-if="!confirming"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
||||
@@ -1,101 +1,106 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Card, CardContent, CardDescription, CardFooter, 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 InputError from '@/Components/InputError.vue';
|
||||
import { CheckCircle, Lock } from 'lucide-vue-next';
|
||||
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>
|
||||
<Card>
|
||||
<form @submit.prevent="updatePassword">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Lock class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Update Password</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Ensure your account is using a long, random password to stay secure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="current_password">Current Password</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>
|
||||
<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>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">New Password</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="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="space-y-2">
|
||||
<Label for="password_confirmation">Confirm Password</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>
|
||||
</CardContent>
|
||||
<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>
|
||||
|
||||
<CardFooter class="flex items-center justify-between">
|
||||
<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">Saved.</span>
|
||||
</div>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
<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 { Card, CardContent, CardDescription, CardFooter, 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 { 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 { 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,189 +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>
|
||||
<Card>
|
||||
<form @submit.prevent="updateProfileInformation">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Update your account's profile information and email address.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
|
||||
<CardContent 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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<Label for="photo">Photo</Label>
|
||||
<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 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" />
|
||||
Select Photo
|
||||
</Button>
|
||||
<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" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<InputError :message="form.errors.photo" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" 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">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
<InputError :message="form.errors.email" class="mt-2" />
|
||||
<!-- 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">
|
||||
Your email address is unverified.
|
||||
<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"
|
||||
>
|
||||
Click here to re-send the verification email.
|
||||
</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>A new verification link has been sent to your email address.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<!-- 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"
|
||||
>
|
||||
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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<CardFooter class="flex items-center justify-between">
|
||||
<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">Saved.</span>
|
||||
</div>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
<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,10 +1,11 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { Link, router, useForm, usePage } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { CardTitle } from "@/Components/ui/card";
|
||||
import { toNumber } from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
segment: Object,
|
||||
@@ -63,6 +65,14 @@ const exportColumns = ref(columns.map((col) => col.key));
|
||||
const exportError = ref("");
|
||||
const isExporting = ref(false);
|
||||
|
||||
const contractTable = ref(null);
|
||||
const selectedRows = ref([]);
|
||||
const showConfirmDialog = ref(false);
|
||||
const archiveForm = useForm({
|
||||
contracts: [],
|
||||
reactivate: false,
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
|
||||
});
|
||||
@@ -78,6 +88,13 @@ const appliedFilterCount = computed(() => {
|
||||
return count;
|
||||
});
|
||||
|
||||
function handleSelectionChange(selectedKeys) {
|
||||
selectedRows.value = selectedKeys.map((val, i) => {
|
||||
const nu = toNumber(val);
|
||||
return props.contracts.data[nu].uuid;
|
||||
});
|
||||
}
|
||||
|
||||
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
|
||||
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
|
||||
const totalContracts = computed(
|
||||
@@ -90,6 +107,11 @@ const exportDisabled = computed(
|
||||
() => exportColumns.value.length === 0 || isExporting.value
|
||||
);
|
||||
|
||||
const canManageSettings = computed(() => {
|
||||
const permissions = usePage().props?.auth?.user?.permissions || [];
|
||||
return permissions.includes("mass-archive");
|
||||
});
|
||||
|
||||
function toggleAllColumns(checked) {
|
||||
exportColumns.value = checked ? columns.map((col) => col.key) : [];
|
||||
}
|
||||
@@ -311,6 +333,36 @@ function extractFilenameFromHeaders(headers) {
|
||||
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||
return asciiMatch?.[1] || null;
|
||||
}
|
||||
|
||||
function openArchiveModal() {
|
||||
console.log(selectedRows.value);
|
||||
if (!selectedRows.value?.length) return;
|
||||
showConfirmDialog.value = true;
|
||||
}
|
||||
|
||||
function closeConfirmDialog() {
|
||||
showConfirmDialog.value = false;
|
||||
}
|
||||
|
||||
function submitArchive() {
|
||||
if (!selectedRows.value?.length) return;
|
||||
|
||||
showConfirmDialog.value = false;
|
||||
|
||||
archiveForm.contracts = [...selectedRows.value];
|
||||
archiveForm.reactivate = false;
|
||||
|
||||
archiveForm.post(route("contracts.archive-batch"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
selectedRows.value = [];
|
||||
if (contractTable.value) {
|
||||
contractTable.value.clearSelection();
|
||||
}
|
||||
router.reload({ only: ["contracts"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -364,10 +416,13 @@ function extractFilenameFromHeaders(headers) {
|
||||
</div>
|
||||
</template>
|
||||
<DataTable
|
||||
ref="contractTable"
|
||||
:columns="columns"
|
||||
:data="contracts?.data || []"
|
||||
:meta="contracts || {}"
|
||||
route-name="segments.show"
|
||||
:enable-row-selection="canManageSettings"
|
||||
@selection:change="handleSelectionChange"
|
||||
:route-params="{ segment: segment?.id ?? segment }"
|
||||
:only-props="['contracts']"
|
||||
:page-size="contracts?.per_page ?? 15"
|
||||
@@ -500,6 +555,17 @@ function extractFilenameFromHeaders(headers) {
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-actions="{ table }">
|
||||
<Button
|
||||
v-if="canManageSettings && table?.getSelectedRowModel()?.rows?.length > 0"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
@click="openArchiveModal"
|
||||
>
|
||||
Arhiviraj ({{ table.getSelectedRowModel().rows.length }})
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template #cell-client_case="{ row }">
|
||||
<Link
|
||||
@@ -541,6 +607,21 @@ function extractFilenameFromHeaders(headers) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showConfirmDialog"
|
||||
title="Arhiviraj pogodbe"
|
||||
:message="`Ali ste prepričani, da želite arhivirati ${
|
||||
selectedRows?.length || 0
|
||||
} pogodb${
|
||||
selectedRows?.length === 1 ? 'o' : ''
|
||||
}? Arhivirane pogodbe bodo odstranjene iz aktivnih segmentov.`"
|
||||
confirm-text="Arhiviraj"
|
||||
cancel-text="Prekliči"
|
||||
:danger="true"
|
||||
@close="closeConfirmDialog"
|
||||
@confirm="submitArchive"
|
||||
/>
|
||||
|
||||
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
|
||||
<template #title>
|
||||
<div class="space-y-1">
|
||||
|
||||
@@ -305,7 +305,7 @@ const destroyAction = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel for="segmentEdit">Segment</InputLabel>
|
||||
<AppCombobox
|
||||
id="segmentEdit"
|
||||
@@ -323,7 +323,7 @@ const destroyAction = () => {
|
||||
v-model="form.decisions"
|
||||
:items="selectOptions"
|
||||
placeholder="Dodaj odločitev"
|
||||
content-class="p-0 w-full"
|
||||
chip-variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -373,7 +373,7 @@ const destroyAction = () => {
|
||||
v-model="createForm.decisions"
|
||||
:items="selectOptions"
|
||||
placeholder="Dodaj odločitev"
|
||||
content-class="p-0 w-full"
|
||||
chip-variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -265,28 +265,30 @@ const update = () => {
|
||||
|
||||
// Transform actions from array of IDs to array of objects
|
||||
const actionsPayload = form.actions
|
||||
.map(id => {
|
||||
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
|
||||
.map((id) => {
|
||||
const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
|
||||
if (!action) {
|
||||
console.warn('Action not found for id:', id);
|
||||
console.warn("Action not found for id:", id);
|
||||
return null;
|
||||
}
|
||||
return { id: action.id, name: action.name };
|
||||
})
|
||||
.filter(Boolean); // Remove null entries
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
actions: actionsPayload
|
||||
})).put(route("settings.decisions.update", { id: form.id }), {
|
||||
onSuccess: () => {
|
||||
closeEditDrawer();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// preserve server errors for display
|
||||
scrollToFirstEventError(form.errors, "edit");
|
||||
},
|
||||
});
|
||||
form
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
actions: actionsPayload,
|
||||
}))
|
||||
.put(route("settings.decisions.update", { id: form.id }), {
|
||||
onSuccess: () => {
|
||||
closeEditDrawer();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// preserve server errors for display
|
||||
scrollToFirstEventError(form.errors, "edit");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const store = () => {
|
||||
@@ -299,27 +301,29 @@ const store = () => {
|
||||
|
||||
// Transform actions from array of IDs to array of objects
|
||||
const actionsPayload = createForm.actions
|
||||
.map(id => {
|
||||
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
|
||||
.map((id) => {
|
||||
const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
|
||||
if (!action) {
|
||||
console.warn('Action not found for id:', id);
|
||||
console.warn("Action not found for id:", id);
|
||||
return null;
|
||||
}
|
||||
return { id: action.id, name: action.name };
|
||||
})
|
||||
.filter(Boolean); // Remove null entries
|
||||
|
||||
createForm.transform((data) => ({
|
||||
...data,
|
||||
actions: actionsPayload
|
||||
})).post(route("settings.decisions.store"), {
|
||||
onSuccess: () => {
|
||||
closeCreateDrawer();
|
||||
},
|
||||
onError: () => {
|
||||
scrollToFirstEventError(createForm.errors, "create");
|
||||
},
|
||||
});
|
||||
createForm
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
actions: actionsPayload,
|
||||
}))
|
||||
.post(route("settings.decisions.store"), {
|
||||
onSuccess: () => {
|
||||
closeCreateDrawer();
|
||||
},
|
||||
onError: () => {
|
||||
scrollToFirstEventError(createForm.errors, "create");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function validateEventsClientSide(events) {
|
||||
@@ -665,7 +669,7 @@ const destroyDecision = () => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2 self-end">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model:checked="ev.active" />
|
||||
<Checkbox v-model="ev.active" />
|
||||
Aktivno
|
||||
</label>
|
||||
<Button
|
||||
@@ -703,7 +707,7 @@ const destroyDecision = () => {
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm mt-6">
|
||||
<Checkbox v-model:checked="ev.config.deactivate_previous" />
|
||||
<Checkbox v-model="ev.config.deactivate_previous" />
|
||||
Deaktiviraj prejšnje
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import DataTableExample from "../Examples/DataTableExample.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
|
||||
|
||||
const props = defineProps({
|
||||
example: { type: String, default: "Demo" },
|
||||
});
|
||||
|
||||
const checkboxValue = ref(false);
|
||||
|
||||
const testForm = useForm({
|
||||
allowed: false,
|
||||
});
|
||||
|
||||
// Dummy columns
|
||||
const columns = [
|
||||
{ key: "id", label: "ID", sortable: true, class: "w-16" },
|
||||
@@ -53,10 +61,17 @@ function onRowClick(row) {
|
||||
// no-op demo; could show toast or details
|
||||
console.debug("Row clicked:", row);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => testForm.allowed,
|
||||
(newVal) => {
|
||||
console.log(newVal);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<DataTableExample></DataTableExample>
|
||||
|
||||
<AppLayout>
|
||||
<Checkbox v-model:checked="testForm.allowed" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
+14
-2
@@ -203,7 +203,14 @@
|
||||
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
|
||||
->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id')
|
||||
->leftJoin('emails', 'person.id', '=', 'emails.person_id')
|
||||
->select('person.*', 'client_cases.uuid as case_uuid', 'client_cases.id as case_id')
|
||||
->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
|
||||
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
|
||||
->select(
|
||||
'person.*',
|
||||
'client_cases.uuid as case_uuid',
|
||||
'client_cases.id as case_id',
|
||||
'client_person.full_name as client_full_name'
|
||||
)
|
||||
->limit($request->input('limit'));
|
||||
})
|
||||
->get();
|
||||
@@ -215,6 +222,8 @@
|
||||
$contractCases = \App\Models\Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->join('person', 'client_cases.person_id', '=', 'person.id')
|
||||
->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
|
||||
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
|
||||
->leftJoin('contract_segment', function ($j) {
|
||||
$j->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
@@ -227,9 +236,10 @@
|
||||
'client_cases.uuid as case_uuid',
|
||||
'client_cases.id as case_id',
|
||||
'contracts.reference as contract_reference',
|
||||
'client_person.full_name as client_full_name',
|
||||
\DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments")
|
||||
)
|
||||
->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference')
|
||||
->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference', 'client_person.full_name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
@@ -324,6 +334,7 @@
|
||||
Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show');
|
||||
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment');
|
||||
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive');
|
||||
Route::post('contracts/archive-batch', [ClientCaseContoller::class, 'archiveBatch'])->name('contracts.archive-batch')->middleware('permission:mass-archive');
|
||||
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
|
||||
Route::post('client-cases/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson');
|
||||
// client-case / contract
|
||||
@@ -452,6 +463,7 @@
|
||||
Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows');
|
||||
Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv');
|
||||
Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview');
|
||||
Route::get('imports/{import}/download', [ImportController::class, 'download'])->name('imports.download');
|
||||
Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts');
|
||||
Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options');
|
||||
// Generic simulation endpoint (new) – provides projected effects for first N rows regardless of payments template
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('downloads the original import file', function () {
|
||||
// Create a test file
|
||||
$uuid = (string) Str::uuid();
|
||||
$disk = 'local';
|
||||
$path = "imports/{$uuid}.csv";
|
||||
$csv = "email,reference\nalpha@example.com,REF-1\n";
|
||||
Storage::disk($disk)->put($path, $csv);
|
||||
|
||||
// Authenticate a user
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
// Create import record
|
||||
$import = Import::create([
|
||||
'uuid' => $uuid,
|
||||
'user_id' => $user->id,
|
||||
'import_template_id' => null,
|
||||
'client_id' => null,
|
||||
'source_type' => 'csv',
|
||||
'file_name' => basename($path),
|
||||
'original_name' => 'test-import.csv',
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'size' => strlen($csv),
|
||||
'status' => 'uploaded',
|
||||
'meta' => ['has_header' => true],
|
||||
]);
|
||||
|
||||
// Test download endpoint
|
||||
$response = test()->get(route('imports.download', ['import' => $import->id]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
expect($response->headers->get('Content-Disposition'))->toContain('test-import.csv');
|
||||
|
||||
// Clean up
|
||||
Storage::disk($disk)->delete($path);
|
||||
});
|
||||
|
||||
it('returns 404 when file does not exist', function () {
|
||||
// Authenticate a user
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
// Create import record with non-existent file
|
||||
$import = Import::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'import_template_id' => null,
|
||||
'client_id' => null,
|
||||
'source_type' => 'csv',
|
||||
'file_name' => 'missing.csv',
|
||||
'original_name' => 'missing.csv',
|
||||
'disk' => 'local',
|
||||
'path' => 'imports/nonexistent.csv',
|
||||
'size' => 0,
|
||||
'status' => 'uploaded',
|
||||
'meta' => ['has_header' => true],
|
||||
]);
|
||||
|
||||
// Test download endpoint
|
||||
$response = test()->get(route('imports.download', ['import' => $import->id]));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
Reference in New Issue
Block a user