Compare commits
43 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 | |||
| f646b6530a | |||
| 7fc4520dbf | |||
| f66bbbf842 | |||
| 4f605451e1 | |||
| dc41862afc | |||
| c4d2f6e473 | |||
| 711438d79f | |||
| fb6474ab88 | |||
| 6871fe8796 | |||
| 137e0b45ad | |||
| 2ad24216ae | |||
| c4d9ecb39e | |||
| 70a5d015e0 | |||
| 8031501d25 |
@@ -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.
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||
{
|
||||
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||
|
||||
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $columnLetterMap = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{label: string}>
|
||||
*/
|
||||
public const COLUMN_METADATA = [
|
||||
'reference' => ['label' => 'Referenca'],
|
||||
'customer' => ['label' => 'Stranka'],
|
||||
'address' => ['label' => 'Naslov'],
|
||||
'start' => ['label' => 'Začetek'],
|
||||
'segment' => ['label' => 'Segment'],
|
||||
'balance' => ['label' => 'Stanje'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $columns
|
||||
*/
|
||||
public function __construct(private Builder $query, private array $columns) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedColumns(): array
|
||||
{
|
||||
return array_keys(self::COLUMN_METADATA);
|
||||
}
|
||||
|
||||
public static function columnLabel(string $column): string
|
||||
{
|
||||
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
$formats = [];
|
||||
|
||||
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||
if ($column === 'reference') {
|
||||
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($column === 'start') {
|
||||
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
return $formats;
|
||||
}
|
||||
|
||||
private function resolveValue(Contract $contract, string $column): mixed
|
||||
{
|
||||
return match ($column) {
|
||||
'reference' => $contract->reference,
|
||||
'customer' => optional($contract->clientCase?->person)->full_name,
|
||||
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||
'start' => $this->formatDate($contract->start_date),
|
||||
'segment' => $contract->segments?->first()?->name,
|
||||
'balance' => optional($contract->account)->balance_amount,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function formatDate(?string $date): mixed
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$carbon = Carbon::parse($date);
|
||||
|
||||
return ExcelDate::dateTimeToExcel($carbon);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getColumnLetterMap(): array
|
||||
{
|
||||
if ($this->columnLetterMap !== []) {
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
$letter = 'A';
|
||||
foreach ($this->columns as $column) {
|
||||
$this->columnLetterMap[$letter] = $column;
|
||||
$letter++;
|
||||
}
|
||||
|
||||
return $this->columnLetterMap;
|
||||
}
|
||||
|
||||
public function bindValue(Cell $cell, $value): bool
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class SegmentContractsExport extends DefaultValueBinder implements FromQuery, Sh
|
||||
public const COLUMN_METADATA = [
|
||||
'reference' => ['label' => 'Pogodba'],
|
||||
'client_case' => ['label' => 'Primer'],
|
||||
'address' => ['label' => 'Naslov'],
|
||||
'client' => ['label' => 'Stranka'],
|
||||
'type' => ['label' => 'Vrsta'],
|
||||
'start_date' => ['label' => 'Začetek'],
|
||||
@@ -107,6 +108,7 @@ private function resolveValue(Contract $contract, string $column): mixed
|
||||
return match ($column) {
|
||||
'reference' => $contract->reference,
|
||||
'client_case' => optional($contract->clientCase?->person)->full_name,
|
||||
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
||||
'type' => optional($contract->type)->name,
|
||||
'start_date' => $this->formatDate($contract->start_date),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
@@ -23,9 +24,19 @@ class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->latest('id')
|
||||
->paginate(20);
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
->where('active', true)
|
||||
@@ -40,6 +51,7 @@ public function index(Request $request): Response
|
||||
->get(['id', 'name', 'content']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
// Provide a lightweight list of recent clients with person names for filtering
|
||||
@@ -58,8 +70,7 @@ public function index(Request $request): Response
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
@@ -312,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
@@ -323,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
@@ -341,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
} else {
|
||||
// Only include contracts that have at least one active, non-excluded segment
|
||||
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||
->from('contract_segment')
|
||||
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||
->where('contract_segment.active', true)
|
||||
->where('segments.exclude', false)
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
);
|
||||
}
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
@@ -390,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->paginate($perPage);
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
$segment = collect($contract->segments)->last();
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
@@ -414,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
// Stranka: the client person
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
@@ -432,12 +453,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $contracts->currentPage(),
|
||||
'last_page' => $contracts->lastPage(),
|
||||
'per_page' => $contracts->perPage(),
|
||||
'total' => $contracts->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -311,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
'contract_uuids' => 'nullable|array',
|
||||
'contract_uuids.*' => 'uuid',
|
||||
'create_for_all_contracts' => 'nullable|boolean',
|
||||
'phone_view' => 'nullable|boolean',
|
||||
'send_auto_mail' => 'sometimes|boolean',
|
||||
'attachment_document_ids' => 'sometimes|array',
|
||||
@@ -318,61 +321,102 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
]);
|
||||
|
||||
$isPhoneView = $attributes['phone_view'] ?? false;
|
||||
$createForAll = $attributes['create_for_all_contracts'] ?? false;
|
||||
$contractUuids = $attributes['contract_uuids'] ?? [];
|
||||
|
||||
// Map contract_uuid to contract_id within the same client case, if provided
|
||||
$contractId = null;
|
||||
if (! empty($attributes['contract_uuid'])) {
|
||||
// Determine which contracts to process
|
||||
$contractIds = [];
|
||||
if ($createForAll && !empty($contractUuids)) {
|
||||
// Get all contract IDs from the provided UUIDs
|
||||
$contracts = Contract::withTrashed()
|
||||
->whereIn('uuid', $contractUuids)
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->get();
|
||||
$contractIds = $contracts->pluck('id')->toArray();
|
||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||
// Single contract mode
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $contractUuids[0])
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->first();
|
||||
if ($contract) {
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
} elseif (!empty($attributes['contract_uuid'])) {
|
||||
// Legacy single contract_uuid support
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $attributes['contract_uuid'])
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->first();
|
||||
if ($contract) {
|
||||
// Archived contracts are allowed: link activity regardless of active flag
|
||||
$contractId = $contract->id;
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
}
|
||||
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
'decision_id' => $attributes['decision_id'],
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
|
||||
if ($isPhoneView && $contractId) {
|
||||
$fieldJob = $contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->where('assigned_user_id', \Auth::id())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($fieldJob) {
|
||||
$fieldJob->update([
|
||||
'added_activity' => true,
|
||||
'last_activity' => $row->created_at,
|
||||
]);
|
||||
|
||||
}
|
||||
// If no contracts specified, create a single activity without contract
|
||||
if (empty($contractIds)) {
|
||||
$contractIds = [null];
|
||||
}
|
||||
|
||||
logger()->info('Activity successfully inserted', $attributes);
|
||||
$createdActivities = [];
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
|
||||
// Disable auto mail if creating activities for multiple contracts
|
||||
if ($sendFlag && count($contractIds) > 1) {
|
||||
$sendFlag = false;
|
||||
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
|
||||
}
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
// Filter attachments to those belonging to the selected contract
|
||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||
->filter()
|
||||
->map(fn ($v) => (int) $v)
|
||||
->values();
|
||||
$validAttachmentIds = collect();
|
||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||
$validAttachmentIds = Document::query()
|
||||
foreach ($contractIds as $contractId) {
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
'decision_id' => $attributes['decision_id'],
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
|
||||
$createdActivities[] = $row;
|
||||
|
||||
if ($isPhoneView && $contractId) {
|
||||
$contract = Contract::find($contractId);
|
||||
if ($contract) {
|
||||
$fieldJob = $contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->where('assigned_user_id', \Auth::id())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($fieldJob) {
|
||||
$fieldJob->update([
|
||||
'added_activity' => true,
|
||||
'last_activity' => $row->created_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
// Filter attachments to those belonging to the selected contract
|
||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||
->filter()
|
||||
->map(fn ($v) => (int) $v)
|
||||
->values();
|
||||
$validAttachmentIds = collect();
|
||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
@@ -383,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
]);
|
||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||
// If template requires contract and user attempted to send, surface a validation message
|
||||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
||||
logger()->warning('Email not queued: required contract is missing for the selected template.');
|
||||
}
|
||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
|
||||
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Do not fail activity creation due to mailing issues
|
||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$activityCount = count($createdActivities);
|
||||
$successMessage = $activityCount > 1
|
||||
? "Successfully created {$activityCount} activities!"
|
||||
: 'Successfully created activity!';
|
||||
|
||||
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
|
||||
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
|
||||
return back(303)->with('success', 'Successful created!')->with('flash_method', 'POST');
|
||||
return back(303)->with('success', $successMessage)->with('flash_method', 'POST');
|
||||
} catch (QueryException $e) {
|
||||
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
||||
|
||||
@@ -1029,6 +1079,156 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive multiple contracts in a batch operation
|
||||
*/
|
||||
public function archiveBatch(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'contracts' => 'required|array',
|
||||
'contracts.*' => 'required|uuid|exists:contracts,uuid',
|
||||
'reactivate' => 'boolean',
|
||||
]);
|
||||
|
||||
$reactivate = $validated['reactivate'] ?? false;
|
||||
|
||||
// Get archive setting
|
||||
$setting = \App\Models\ArchiveSetting::query()
|
||||
->where('enabled', true)
|
||||
->whereIn('strategy', ['immediate', 'manual'])
|
||||
->where('reactivate', $reactivate)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $setting) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
}
|
||||
|
||||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||
$successCount = 0;
|
||||
$skippedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($validated['contracts'] as $contractUuid) {
|
||||
try {
|
||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (!$contract->active) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientCase = $contract->clientCase;
|
||||
|
||||
$context = [
|
||||
'contract_id' => $contract->id,
|
||||
'client_case_id' => $clientCase->id,
|
||||
'account_id' => $contract->account->id ?? null,
|
||||
];
|
||||
|
||||
// Execute archive setting
|
||||
$executor->executeSetting($setting, $context, \Auth::id());
|
||||
|
||||
// Transaction for segment updates and activity logging
|
||||
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||
// Create activity log
|
||||
if ($setting->action_id && $setting->decision_id) {
|
||||
$activityData = [
|
||||
'client_case_id' => $clientCase->id,
|
||||
'action_id' => $setting->action_id,
|
||||
'decision_id' => $setting->decision_id,
|
||||
'note' => ($reactivate)
|
||||
? "Ponovno aktivirana pogodba $contract->reference"
|
||||
: "Arhivirana pogodba $contract->reference",
|
||||
];
|
||||
|
||||
try {
|
||||
\App\Models\Activity::create($activityData);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Activity could not be created during batch archive');
|
||||
}
|
||||
}
|
||||
|
||||
// Move to archive segment if specified
|
||||
if ($setting->segment_id) {
|
||||
$segmentId = $setting->segment_id;
|
||||
|
||||
// Deactivate all current segments
|
||||
$contract->segments()
|
||||
->allRelatedIds()
|
||||
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
|
||||
'active' => false,
|
||||
'updated_at' => now(),
|
||||
]));
|
||||
|
||||
// Activate archive segment
|
||||
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||
'active' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$contract->segments()->attach($segmentId, [
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel pending field jobs
|
||||
$contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->update([
|
||||
'cancelled_at' => date('Y-m-d'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$successCount++;
|
||||
} catch (Exception $e) {
|
||||
\Log::error('Error archiving contract in batch', [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors[] = [
|
||||
'uuid' => $contractUuid,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
$message = "Archived $successCount contracts";
|
||||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
'details' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $reactivate
|
||||
? "Successfully reactivated $successCount contracts"
|
||||
: "Successfully archived $successCount contracts";
|
||||
|
||||
if ($skippedCount > 0) {
|
||||
$message .= " ($skippedCount already archived)";
|
||||
}
|
||||
|
||||
return back()->with('flash', [
|
||||
'success' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use App\Http\Requests\ExportClientContractsRequest;
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('clients.active', 1)
|
||||
//->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
@@ -47,7 +51,7 @@ public function index(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->paginate($request->integer('per_page', default: 100))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -137,6 +141,7 @@ public function contracts(Client $client, Request $request)
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
@@ -166,6 +171,84 @@ public function contracts(Client $client, Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportContracts(ExportClientContractsRequest $request, Client $client)
|
||||
{
|
||||
$data = $request->validated();
|
||||
$columns = array_values(array_unique($data['columns']));
|
||||
|
||||
$from = $data['from'] ?? null;
|
||||
$to = $data['to'] ?? null;
|
||||
$search = $data['search'] ?? null;
|
||||
$segmentsParam = $data['segments'] ?? null;
|
||||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||
|
||||
$query = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'clientCase.person.address',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
|
||||
$page = max(1, (int) ($data['page'] ?? 1));
|
||||
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||
$query->forPage($page, $perPage);
|
||||
}
|
||||
|
||||
$filename = $this->buildExportFilename($client);
|
||||
|
||||
return Excel::download(new ClientContractsExport($query, $columns), $filename);
|
||||
}
|
||||
|
||||
private function buildExportFilename(Client $client): string
|
||||
{
|
||||
$datePrefix = now()->format('dmy');
|
||||
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||
|
||||
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
|
||||
}
|
||||
|
||||
private function slugify(?string $value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return 'data';
|
||||
}
|
||||
|
||||
return Str::slug($value, '-') ?: 'data';
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ public function index(Request $request)
|
||||
$unassignedClients = $unassignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id');
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
|
||||
$assignedContracts = Contract::query()
|
||||
@@ -98,7 +99,8 @@ public function index(Request $request)
|
||||
$assignedClients = $assignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id');
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Services\CsvImportService;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -184,12 +183,13 @@ public function store(Request $request)
|
||||
}
|
||||
|
||||
// Kick off processing of an import - simple synchronous step for now
|
||||
public function process(Import $import, Request $request, ImportServiceV2 $processor)
|
||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +657,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
||||
|
||||
$import->update([
|
||||
'import_template_id' => $template->id,
|
||||
'reactivate' => $template->reactivate,
|
||||
'meta' => $merged,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,8 +13,12 @@ public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
$query = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
@@ -23,32 +27,78 @@ public function index(Request $request)
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.person.address.type',
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('assigned_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
->orderByDesc('assigned_at');
|
||||
|
||||
// Apply client filter
|
||||
if ($clientFilter) {
|
||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
})
|
||||
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$jobs = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get unique clients for filter dropdown
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at');
|
||||
})
|
||||
->with(['person:id,full_name'])
|
||||
->get(['uuid', 'person_id'])
|
||||
->map(fn ($c) => [
|
||||
'uuid' => (string) $c->uuid,
|
||||
'name' => (string) optional($c->person)->full_name,
|
||||
])
|
||||
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'assigned',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'client' => $clientFilter,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedToday(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
$query = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
@@ -57,21 +107,63 @@ public function completedToday(Request $request)
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.person.address.type',
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('completed_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
->orderByDesc('completed_at');
|
||||
|
||||
// Apply client filter
|
||||
if ($clientFilter) {
|
||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
})
|
||||
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$jobs = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get unique clients for filter dropdown
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end]);
|
||||
})
|
||||
->with(['person:id,full_name'])
|
||||
->get(['uuid', 'person_id'])
|
||||
->map(fn ($c) => [
|
||||
'uuid' => (string) $c->uuid,
|
||||
'name' => (string) optional($c->person)->full_name,
|
||||
])
|
||||
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'completed-today',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'client' => $clientFilter,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -81,7 +173,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||
$completedMode = $request->boolean('completed');
|
||||
|
||||
// Eager load case with person details
|
||||
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||
$case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||
|
||||
// Query contracts based on field jobs
|
||||
$contractsQuery = FieldJob::query()
|
||||
@@ -131,7 +223,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||
->unique();
|
||||
|
||||
return Inertia::render('Phone/Case/Index', [
|
||||
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||
'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||
'client_case' => $case,
|
||||
'contracts' => $contracts,
|
||||
'documents' => $documents,
|
||||
|
||||
@@ -64,6 +64,12 @@ public function show(Segment $segment)
|
||||
->withQueryString();
|
||||
|
||||
$contracts = $this->hydrateClientShortcut($contracts);
|
||||
|
||||
// Hide addresses array since we're using the singular address relationship
|
||||
$contracts->getCollection()->each(function ($contract) {
|
||||
$contract->clientCase?->person?->makeHidden('addresses');
|
||||
$contract->clientCase?->client?->person?->makeHidden('addresses');
|
||||
});
|
||||
|
||||
$clients = Client::query()
|
||||
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
||||
@@ -191,8 +197,7 @@ private function buildContractsQuery(Segment $segment, ?string $search, ?string
|
||||
->where('contract_segment.active', '=', 1);
|
||||
})
|
||||
->with([
|
||||
'clientCase.person',
|
||||
'clientCase.client.person',
|
||||
'clientCase.person.address',
|
||||
'type',
|
||||
'account',
|
||||
])
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Exports\ClientContractsExport;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExportClientContractsRequest extends FormRequest
|
||||
{
|
||||
public const SCOPE_CURRENT = 'current';
|
||||
|
||||
public const SCOPE_ALL = 'all';
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$columnRule = Rule::in(ClientContractsExport::allowedColumns());
|
||||
|
||||
return [
|
||||
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||
'columns' => ['required', 'array', 'min:1'],
|
||||
'columns.*' => ['string', $columnRule],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
'segments' => ['nullable', 'string'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
|
||||
if ($template && $case) {
|
||||
$note = '';
|
||||
if ($log->status === 'sent') {
|
||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
} elseif ($log->status === 'failed') {
|
||||
$note = sprintf(
|
||||
'Št: %s | Telo: %s | Napaka: %s',
|
||||
'Tel: %s | Telo: %s | Napaka: %s',
|
||||
(string) $this->to,
|
||||
(string) $this->content,
|
||||
'SMS ni bil poslan!'
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -29,6 +29,7 @@ class Contract extends Model
|
||||
'end_date',
|
||||
'client_case_id',
|
||||
'type_id',
|
||||
'active',
|
||||
'description',
|
||||
'meta',
|
||||
];
|
||||
|
||||
@@ -46,6 +46,7 @@ class Person extends Model
|
||||
'group_id',
|
||||
'type_id',
|
||||
'user_id',
|
||||
'employer'
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -112,6 +113,14 @@ public function addresses(): HasMany
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function address(): HasOne
|
||||
{
|
||||
return $this->hasOne(\App\Models\Person\PersonAddress::class)
|
||||
->with(['type'])
|
||||
->where('active', '=', 1)
|
||||
->oldestOfMany('id');
|
||||
}
|
||||
|
||||
public function emails(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Email::class, 'person_id')
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -46,76 +46,35 @@ public function resolve(array $mapped, array $context = []): mixed
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// PHASE 4: Check for existing Contract early to prevent duplicate creation
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
|
||||
if ($reference) {
|
||||
$existingContract = $this->resolutionService->getExistingContract(
|
||||
$import->client_id,
|
||||
$reference
|
||||
);
|
||||
|
||||
if ($existingContract) {
|
||||
Log::info('ContractHandler: Found existing Contract by reference', [
|
||||
'contract_id' => $existingContract->id,
|
||||
'reference' => $reference,
|
||||
]);
|
||||
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existingContract,
|
||||
'message' => 'Contract already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
// Update existing contract
|
||||
$payload = $this->buildPayload($mapped, $existingContract);
|
||||
$payload = $this->mergeJsonFields($payload, $existingContract);
|
||||
$appliedFields = $this->trackAppliedFields($existingContract, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existingContract,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existingContract->fill($payload);
|
||||
$existingContract->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existingContract,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing contract (using resolve method which handles client scoping)
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
// Check for reactivation request
|
||||
$reactivate = $this->shouldReactivate($context);
|
||||
|
||||
// Handle reactivation if entity is soft-deleted or inactive
|
||||
if ($existing && $reactivate && $this->needsReactivation($existing)) {
|
||||
$reactivated = $this->attemptReactivation($existing, $context);
|
||||
if ($reactivated) {
|
||||
return [
|
||||
'action' => 'reactivated',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract reactivated',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we should update or skip based on mode
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($existing) {
|
||||
|
||||
|
||||
// Check for reactivation FIRST (before update_mode check)
|
||||
$reactivate = $this->shouldReactivate($context);
|
||||
|
||||
Log::info('ContractHandler: Found existing Contract', [
|
||||
'contract_id' => $existing->id,
|
||||
'reference' => $mapped['reference'] ?? null,
|
||||
'context' => $context['import']
|
||||
]);
|
||||
|
||||
if ($reactivate && $this->needsReactivation($existing)) {
|
||||
$reactivated = $this->attemptReactivation($existing, $context);
|
||||
Log::info('ContractHandler: Reactivate', ['reactivated' => $reactivated]);
|
||||
if ($reactivated) {
|
||||
return [
|
||||
'action' => 'reactivated',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract reactivated',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check update mode
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
@@ -124,12 +83,9 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||
];
|
||||
}
|
||||
|
||||
// Update
|
||||
// Update existing contract
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
|
||||
// Merge JSON fields instead of overwriting
|
||||
$payload = $this->mergeJsonFields($payload, $existing);
|
||||
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
@@ -200,7 +156,6 @@ protected function buildPayload(array $mapped, $model): array
|
||||
// Map fields according to contract schema
|
||||
$fieldMap = [
|
||||
'reference' => 'reference',
|
||||
'title' => 'title',
|
||||
'description' => 'description',
|
||||
'amount' => 'amount',
|
||||
'currency' => 'currency',
|
||||
@@ -286,7 +241,9 @@ protected function attemptReactivation(Contract $contract, array $context): bool
|
||||
$contract->restore();
|
||||
}
|
||||
|
||||
$contract->update(['active' => 1]);
|
||||
$contract->active = 1;
|
||||
|
||||
$contract->save();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -100,8 +100,6 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
$rowNum++;
|
||||
}
|
||||
|
||||
$isPg = DB::connection()->getDriverName() === 'pgsql';
|
||||
|
||||
// If retry mode, only process failed/invalid rows
|
||||
if ($isRetry) {
|
||||
$failedRows = ImportRow::where('import_id', $import->id)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
$existing = Account::query()
|
||||
->where('contract_id', $contractId)
|
||||
->where('reference', $reference)
|
||||
//->where('reference', $reference)
|
||||
->where('active', 1)
|
||||
->first();
|
||||
|
||||
@@ -1655,6 +1656,14 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
$value = $acc[$field] ?? null;
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||
$value = $this->normalizeDecimal($value);
|
||||
// Ensure the normalized value is numeric, otherwise default to 0
|
||||
if ($value === '' || $value === '-' || ! is_numeric($value)) {
|
||||
$value = 0;
|
||||
}
|
||||
}
|
||||
// Convert empty string to 0 for amount fields
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
|
||||
$value = 0;
|
||||
}
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if ($mode === 'keyref') {
|
||||
@@ -1684,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
if ($existing) {
|
||||
// Build non-null changes for account fields
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
// Track balance change
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
// Track balance change - normalize in case DB has malformed data
|
||||
$rawBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawBalance) && $rawBalance !== '') {
|
||||
$rawBalance = $this->normalizeDecimal($rawBalance);
|
||||
}
|
||||
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
|
||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
@@ -1694,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
||||
$rawNewBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
|
||||
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
|
||||
}
|
||||
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
|
||||
if ($newBalance !== $oldBalance) {
|
||||
try {
|
||||
$contractId = $existing->contract_id;
|
||||
@@ -2301,10 +2318,31 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
|
||||
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
|
||||
}
|
||||
|
||||
// Check if contract is soft-deleted and needs reactivation
|
||||
$isTrashed = $existing->trashed();
|
||||
|
||||
// 1) Prepare contract field changes (non-null)
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
|
||||
// 2) Handle reactivation defaults when contract is soft-deleted
|
||||
if ($isTrashed || $existing->active == 0) {
|
||||
// Check if start_date is in the mappings
|
||||
$hasStartDateMapping = $this->mappingIncludes($mappings, 'contract.start_date');
|
||||
if (!$hasStartDateMapping) {
|
||||
// Default to current date when not in mappings
|
||||
$changes['start_date'] = now()->toDateString();
|
||||
}
|
||||
|
||||
// Check if end_date is in the mappings
|
||||
$hasEndDateMapping = $this->mappingIncludes($mappings, 'contract.end_date');
|
||||
if (!$hasEndDateMapping) {
|
||||
// Default to null when not in mappings
|
||||
$changes['end_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Prepare meta changes if provided via mapping
|
||||
// 3) Prepare meta changes if provided via mapping
|
||||
$metaUpdated = false;
|
||||
$metaAppliedKeys = [];
|
||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||
@@ -2347,7 +2385,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changes) && ! $metaUpdated) {
|
||||
if (empty($changes) && ! $metaUpdated && ! $isTrashed) {
|
||||
// Nothing to change
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
||||
}
|
||||
@@ -2355,6 +2393,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
}
|
||||
|
||||
// Restore soft-deleted contract if it was trashed
|
||||
if ($isTrashed) {
|
||||
$existing->restore();
|
||||
}
|
||||
|
||||
$existing->save();
|
||||
|
||||
// Build applied fields info, include meta keys if any
|
||||
@@ -2364,8 +2408,10 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
$applied['meta:'.$k] = 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
$actionType = $isTrashed ? 'reactivated' : 'updated';
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
|
||||
return ['action' => $actionType, 'contract' => $existing, 'applied_fields' => $applied];
|
||||
} else {
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||
@@ -2941,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];
|
||||
}
|
||||
@@ -2954,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();
|
||||
@@ -3130,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 = [];
|
||||
@@ -3178,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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -17,11 +17,11 @@ public function run(): void
|
||||
'key' => 'contracts',
|
||||
'canonical_root' => 'contract',
|
||||
'label' => 'Pogodbe',
|
||||
'fields' => ['reference', 'title', 'description', 'amount', 'currency', 'start_date', 'end_date', 'active'],
|
||||
'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id', 'meta'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['contract', 'contracts'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'meta' => true,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'reference', 'order' => 1],
|
||||
'handler_class' => \App\Services\Import\Handlers\ContractHandler::class,
|
||||
@@ -45,7 +45,7 @@ public function run(): void
|
||||
'key' => 'accounts',
|
||||
'canonical_root' => 'account',
|
||||
'label' => 'Računi',
|
||||
'fields' => ['contract_id', 'reference', 'title', 'description', 'balance_amount', 'currency'],
|
||||
'fields' => ['reference', 'initial_amount', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['account', 'accounts'],
|
||||
'supports_multiple' => false,
|
||||
@@ -75,8 +75,26 @@ public function run(): void
|
||||
'key' => 'payments',
|
||||
'canonical_root' => 'payment',
|
||||
'label' => 'Plačila',
|
||||
'fields' => ['account_id', 'reference', 'amount', 'currency', 'paid_at', 'payment_date'],
|
||||
'field_aliases' => ['payment_date' => 'paid_at'],
|
||||
'fields' => [
|
||||
'reference',
|
||||
'payment_nu',
|
||||
'payment_date',
|
||||
'amount',
|
||||
'type_id',
|
||||
'active',
|
||||
// optional helpers for mapping by related records
|
||||
'debt_id',
|
||||
'account_id',
|
||||
'account_reference',
|
||||
'contract_reference'
|
||||
],
|
||||
'field_aliases' => [
|
||||
'datum' => 'payment_date',
|
||||
'paid_at' => 'payment_date',
|
||||
'number' => 'payment_nu',
|
||||
'znesek' => 'amount',
|
||||
'value' => 'amount'
|
||||
],
|
||||
'aliases' => ['payment', 'payments'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
|
||||
@@ -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
|
||||
Generated
+105
-4
@@ -10,6 +10,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"@guolao/vue-monaco-editor": "^1.6.0",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
@@ -27,9 +28,10 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"material-design-icons-iconfont": "^6.7.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"preline": "^2.7.0",
|
||||
"quill": "^1.3.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-inner-border": "^0.2.0",
|
||||
@@ -879,6 +881,52 @@
|
||||
"vue": ">= 3.0.0 < 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@guolao/vue-monaco-editor": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.6.0.tgz",
|
||||
"integrity": "sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.6.1",
|
||||
"vue-demi": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.7.2",
|
||||
"monaco-editor": ">=0.43.0",
|
||||
"vue": "^2.6.14 || >=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/vue": {
|
||||
"version": "1.7.23",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||
@@ -1069,6 +1117,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -2309,6 +2366,13 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
@@ -3616,6 +3680,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -4664,6 +4737,18 @@
|
||||
"vt-pbf": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/material-design-icons-iconfont": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/material-design-icons-iconfont/-/material-design-icons-iconfont-6.7.0.tgz",
|
||||
@@ -4736,6 +4821,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5074,9 +5169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.1.tgz",
|
||||
"integrity": "sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz",
|
||||
"integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
@@ -5386,6 +5481,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/striptags": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -30,6 +30,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"@guolao/vue-monaco-editor": "^1.6.0",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
@@ -47,9 +48,10 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"material-design-icons-iconfont": "^6.7.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"preline": "^2.7.0",
|
||||
"quill": "^1.3.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-inner-border": "^0.2.0",
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick } from 'vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import InputError from './InputError.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
import SecondaryButton from './SecondaryButton.vue';
|
||||
import TextInput from './TextInput.vue';
|
||||
import { ref, reactive, nextTick } from "vue";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
import SecondaryButton from "./SecondaryButton.vue";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
const emit = defineEmits(['confirmed']);
|
||||
const emit = defineEmits(["confirmed"]);
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirm Password',
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: 'For your security, please confirm your password to continue.',
|
||||
},
|
||||
button: {
|
||||
type: String,
|
||||
default: 'Confirm',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "Confirm Password",
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: "For your security, please confirm your password to continue.",
|
||||
},
|
||||
button: {
|
||||
type: String,
|
||||
default: "Confirm",
|
||||
},
|
||||
});
|
||||
|
||||
const confirmingPassword = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
password: '',
|
||||
error: '',
|
||||
processing: false,
|
||||
password: "",
|
||||
error: "",
|
||||
processing: false,
|
||||
});
|
||||
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const startConfirmingPassword = () => {
|
||||
axios.get(route('password.confirmation')).then(response => {
|
||||
if (response.data.confirmed) {
|
||||
emit('confirmed');
|
||||
} else {
|
||||
confirmingPassword.value = true;
|
||||
axios.get(route("password.confirmation")).then((response) => {
|
||||
if (response.data.confirmed) {
|
||||
emit("confirmed");
|
||||
} else {
|
||||
confirmingPassword.value = true;
|
||||
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
}
|
||||
});
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const confirmPassword = () => {
|
||||
form.processing = true;
|
||||
form.processing = true;
|
||||
|
||||
axios.post(route('password.confirm'), {
|
||||
password: form.password,
|
||||
}).then(() => {
|
||||
form.processing = false;
|
||||
axios
|
||||
.post(route("password.confirm"), {
|
||||
password: form.password,
|
||||
})
|
||||
.then(() => {
|
||||
form.processing = false;
|
||||
|
||||
closeModal();
|
||||
nextTick().then(() => emit('confirmed'));
|
||||
|
||||
}).catch(error => {
|
||||
form.processing = false;
|
||||
form.error = error.response.data.errors.password[0];
|
||||
passwordInput.value.focus();
|
||||
closeModal();
|
||||
nextTick().then(() => emit("confirmed"));
|
||||
})
|
||||
.catch((error) => {
|
||||
form.processing = false;
|
||||
form.error = error.response.data.errors.password[0];
|
||||
passwordInput.value.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
confirmingPassword.value = false;
|
||||
form.password = '';
|
||||
form.error = '';
|
||||
confirmingPassword.value = false;
|
||||
form.password = "";
|
||||
form.error = "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<span @click="startConfirmingPassword">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<DialogModal :show="confirmingPassword" @close="closeModal">
|
||||
<template #title>
|
||||
{{ title }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
{{ content }}
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="confirmPassword"
|
||||
/>
|
||||
|
||||
<InputError :message="form.error" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="confirmPassword"
|
||||
>
|
||||
{{ button }}
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<span>
|
||||
<span @click="startConfirmingPassword">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<DialogModal :show="confirmingPassword" @close="closeModal">
|
||||
<template #title>
|
||||
{{ title }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
{{ content }}
|
||||
|
||||
<div class="mt-4">
|
||||
<Input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="confirmPassword"
|
||||
/>
|
||||
|
||||
<InputError :message="form.error" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="confirmPassword"
|
||||
>
|
||||
{{ button }}
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -462,6 +462,17 @@ function keyOf(row) {
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
clearSelection: () => {
|
||||
table.resetRowSelection();
|
||||
rowSelection.value = {};
|
||||
},
|
||||
getSelectedRows: () => {
|
||||
return Object.keys(rowSelection.value).filter((key) => rowSelection.value[key]);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -6,34 +6,40 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ref, watch } from 'vue';
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Izbriši' },
|
||||
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
|
||||
confirmText: { type: String, default: 'Izbriši' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
title: { type: String, default: "Izbriši" },
|
||||
message: {
|
||||
type: String,
|
||||
default: "Ali ste prepričani, da želite izbrisati ta element?",
|
||||
},
|
||||
confirmText: { type: String, default: "Izbriši" },
|
||||
cancelText: { type: String, default: "Prekliči" },
|
||||
processing: { type: Boolean, default: false },
|
||||
itemName: { type: String, default: null }, // Optional name to show in confirmation
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
||||
const emit = defineEmits(["update:show", "close", "confirm"]);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
open.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
emit("update:show", newVal);
|
||||
if (!newVal) {
|
||||
emit('close');
|
||||
emit("close");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +48,7 @@ const onClose = () => {
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
emit("confirm");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -59,8 +65,13 @@ const onConfirm = () => {
|
||||
<DialogDescription>
|
||||
<div class="flex items-start gap-4 pt-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" />
|
||||
<div
|
||||
class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faTriangleExclamation"
|
||||
class="h-6 w-6 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
@@ -70,9 +81,7 @@ const onConfirm = () => {
|
||||
<p v-if="itemName" class="text-sm font-medium text-gray-900">
|
||||
{{ itemName }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ta dejanje ni mogoče razveljaviti.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
@@ -82,15 +91,10 @@ const onConfirm = () => {
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
:disabled="processing"
|
||||
>
|
||||
<Button variant="destructive" @click="onConfirm" :disabled="processing">
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
<script setup>
|
||||
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { ref, watch } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form'
|
||||
import { Input } from '@/Components/ui/input'
|
||||
import { Textarea } from '@/Components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
|
||||
import { Switch } from '@/Components/ui/switch'
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { ref, watch } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
@@ -17,112 +29,128 @@ const props = defineProps({
|
||||
// Optional list of contracts to allow attaching the document directly to a contract
|
||||
// Each item should have at least: { uuid, reference }
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'uploaded'])
|
||||
});
|
||||
const emit = defineEmits(["close", "uploaded"]);
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
|
||||
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
|
||||
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
|
||||
const ALLOWED_EXTS = [
|
||||
"doc",
|
||||
"docx",
|
||||
"pdf",
|
||||
"txt",
|
||||
"csv",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
];
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1, 'Ime je obvezno'),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
}))
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
name: z.string().min(1, "Ime je obvezno"),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
name: "",
|
||||
description: "",
|
||||
file: null,
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const localError = ref('')
|
||||
const localError = ref("");
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) return
|
||||
localError.value = ''
|
||||
form.resetForm()
|
||||
})
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return;
|
||||
localError.value = "";
|
||||
form.resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
const onFileChange = (e) => {
|
||||
localError.value = ''
|
||||
const f = e.target.files?.[0]
|
||||
localError.value = "";
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) {
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase()
|
||||
const ext = (f.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||
e.target.value = "";
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||
e.target.value = "";
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('file', f)
|
||||
form.setFieldValue("file", f);
|
||||
if (!form.values.name) {
|
||||
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
|
||||
form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submit = form.handleSubmit(async (values) => {
|
||||
localError.value = ''
|
||||
localError.value = "";
|
||||
if (!values.file) {
|
||||
localError.value = 'Prosimo izberite datoteko.'
|
||||
return
|
||||
localError.value = "Prosimo izberite datoteko.";
|
||||
return;
|
||||
}
|
||||
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
|
||||
const ext = (values.file.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
return
|
||||
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||
return;
|
||||
}
|
||||
if (values.file.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
return
|
||||
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('name', values.name)
|
||||
formData.append('description', values.description || '')
|
||||
formData.append('file', values.file)
|
||||
formData.append('is_public', values.is_public ? '1' : '0')
|
||||
const formData = new FormData();
|
||||
formData.append("name", values.name);
|
||||
formData.append("description", values.description || "");
|
||||
formData.append("file", values.file);
|
||||
formData.append("is_public", values.is_public ? "1" : "0");
|
||||
if (values.contract_uuid) {
|
||||
formData.append('contract_uuid', values.contract_uuid)
|
||||
formData.append("contract_uuid", values.contract_uuid);
|
||||
}
|
||||
|
||||
router.post(props.postUrl, formData, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
emit('uploaded')
|
||||
emit('close')
|
||||
form.resetForm()
|
||||
emit("uploaded");
|
||||
emit("close");
|
||||
form.resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Set form errors if any
|
||||
if (errors.name) form.setFieldError('name', errors.name)
|
||||
if (errors.description) form.setFieldError('description', errors.description)
|
||||
if (errors.file) form.setFieldError('file', errors.file)
|
||||
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
|
||||
if (errors.name) form.setFieldError("name", errors.name);
|
||||
if (errors.description) form.setFieldError("description", errors.description);
|
||||
if (errors.file) form.setFieldError("file", errors.file);
|
||||
if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const close = () => emit('close')
|
||||
const close = () => emit("close");
|
||||
|
||||
const onConfirm = () => {
|
||||
submit()
|
||||
}
|
||||
submit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -137,7 +165,11 @@ const onConfirm = () => {
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid">
|
||||
<FormField
|
||||
v-if="props.contracts && props.contracts.length"
|
||||
v-slot="{ value, handleChange }"
|
||||
name="contract_uuid"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>Pripiši k</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
@@ -148,11 +180,7 @@ const onConfirm = () => {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Primer</SelectItem>
|
||||
<SelectItem
|
||||
v-for="c in props.contracts"
|
||||
:key="c.uuid"
|
||||
:value="c.uuid"
|
||||
>
|
||||
<SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
|
||||
Pogodba: {{ c.reference }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -165,7 +193,11 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Ime</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="doc_name" v-bind="componentField" />
|
||||
<Input
|
||||
id="doc_name"
|
||||
v-bind="componentField"
|
||||
class="w-full max-w-full overflow-hidden text-ellipsis"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -184,29 +216,24 @@ const onConfirm = () => {
|
||||
<FormField v-slot="{ value, handleChange }" name="file">
|
||||
<FormItem>
|
||||
<FormLabel>Datoteka (max 25MB)</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl class="flex w-full">
|
||||
<Input
|
||||
id="doc_file"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
|
||||
class="min-w-0 w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
||||
<div v-if="value" class="text-sm text-gray-600 mt-1">
|
||||
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="is_public">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>Javno</FormLabel>
|
||||
|
||||
@@ -1,30 +1,219 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog'
|
||||
import { Button } from '@/Components/ui/button'
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
src: { type: String, default: '' },
|
||||
title: { type: String, default: 'Dokument' }
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
src: { type: String, default: "" },
|
||||
title: { type: String, default: "Dokument" },
|
||||
mimeType: { type: String, default: "" },
|
||||
filename: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const textContent = ref("");
|
||||
const loading = ref(false);
|
||||
const previewGenerating = ref(false);
|
||||
const previewError = ref("");
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (props.filename) {
|
||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const viewerType = computed(() => {
|
||||
const ext = fileExtension.value;
|
||||
const mime = props.mimeType.toLowerCase();
|
||||
|
||||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
|
||||
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||||
return "docx";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
|
||||
return "image";
|
||||
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||||
|
||||
return "unsupported";
|
||||
});
|
||||
|
||||
const loadTextContent = async () => {
|
||||
if (!props.src || viewerType.value !== "text") return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get(props.src);
|
||||
textContent.value = response.data;
|
||||
} catch (e) {
|
||||
textContent.value = "Napaka pri nalaganju vsebine.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
|
||||
// we poll until it's available.
|
||||
const docxPreviewUrl = ref("");
|
||||
const loadDocxPreview = async () => {
|
||||
if (!props.src || viewerType.value !== "docx") return;
|
||||
|
||||
previewGenerating.value = true;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
|
||||
const maxRetries = 15;
|
||||
const retryDelay = 2000; // 2 seconds between retries
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.head(props.src, { validateStatus: () => true });
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
// Preview is ready
|
||||
docxPreviewUrl.value = props.src;
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
} else if (response.status === 202) {
|
||||
// Preview is being generated, wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
// Other error
|
||||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Max retries reached
|
||||
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||||
previewGenerating.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.show, props.src],
|
||||
([show]) => {
|
||||
if (show && viewerType.value === "text") {
|
||||
loadTextContent();
|
||||
}
|
||||
if (show && viewerType.value === "docx") {
|
||||
loadDocxPreview();
|
||||
}
|
||||
// Reset states when dialog closes
|
||||
if (!show) {
|
||||
previewGenerating.value = false;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
||||
<DialogContent class="max-w-4xl">
|
||||
<DialogContent class="max-w-full xl:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Badge>
|
||||
{{ fileExtension }}
|
||||
</Badge>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="h-[70vh]">
|
||||
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
|
||||
|
||||
<div class="h-[70vh] overflow-auto">
|
||||
<!-- PDF Viewer (browser native) -->
|
||||
<template v-if="viewerType === 'pdf' && props.src">
|
||||
<iframe
|
||||
:src="props.src"
|
||||
class="w-full h-full rounded border"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||||
<template v-else-if="viewerType === 'docx'">
|
||||
<!-- Loading/generating state -->
|
||||
<div
|
||||
v-if="previewGenerating"
|
||||
class="flex flex-col items-center justify-center h-full gap-4"
|
||||
>
|
||||
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||||
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="previewError"
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||
>
|
||||
<span>{{ previewError }}</span>
|
||||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||
Prenesi datoteko
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Preview ready -->
|
||||
<iframe
|
||||
v-else-if="docxPreviewUrl"
|
||||
:src="docxPreviewUrl"
|
||||
class="w-full h-full rounded border"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Image Viewer -->
|
||||
<template v-else-if="viewerType === 'image' && props.src">
|
||||
<img
|
||||
:src="props.src"
|
||||
:alt="props.title"
|
||||
class="max-w-full max-h-full mx-auto object-contain"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Text/CSV/XML Viewer -->
|
||||
<template v-else-if="viewerType === 'text'">
|
||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
|
||||
>{{ textContent }}</pre
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Unsupported -->
|
||||
<template v-else-if="viewerType === 'unsupported'">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||
>
|
||||
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||
Prenesi datoteko
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No source -->
|
||||
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
as-child
|
||||
:default-open="item.isActive"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<a :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -7,12 +7,7 @@ import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -97,7 +92,7 @@ watch(
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: a.description || "",
|
||||
});
|
||||
return;
|
||||
@@ -108,52 +103,51 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.edit && props.id) {
|
||||
const a = props.person.addresses?.find((x) => x.id === props.id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
description: a.description || "",
|
||||
});
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val && props.edit && props.id) {
|
||||
const a = props.person.addresses?.find((x) => x.id === props.id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: a.description || "",
|
||||
});
|
||||
}
|
||||
} else if (val && !props.edit) {
|
||||
resetForm();
|
||||
}
|
||||
} else if (val && !props.edit) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.post(
|
||||
route("person.address.create", props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
router.post(route("person.address.create", props.person), values, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
@@ -223,7 +217,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Naslov"
|
||||
autocomplete="street-address"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -233,7 +232,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Država"
|
||||
autocomplete="country"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -243,7 +247,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Poštna številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Poštna številka"
|
||||
autocomplete="postal-code"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -253,7 +262,22 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Mesto</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Mesto"
|
||||
autocomplete="address-level2"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -85,7 +80,7 @@ const hydrate = () => {
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||
description: a.description || "",
|
||||
});
|
||||
return;
|
||||
@@ -94,10 +89,17 @@ const hydrate = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
watch(() => props.id, () => hydrate(), { immediate: true });
|
||||
watch(() => props.show, (v) => {
|
||||
if (v) hydrate();
|
||||
});
|
||||
watch(
|
||||
() => props.id,
|
||||
() => hydrate(),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) hydrate();
|
||||
}
|
||||
);
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
@@ -157,7 +159,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Naslov"
|
||||
autocomplete="street-address"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -167,7 +174,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Država"
|
||||
autocomplete="country"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -177,7 +189,12 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Poštna številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Poštna številka"
|
||||
autocomplete="postal-code"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -187,7 +204,22 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Mesto</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Mesto"
|
||||
autocomplete="address-level2"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<Card class="p-2 gap-1" v-for="address in person.addresses" :key="address.id">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
@@ -61,13 +61,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
|
||||
<p class="font-medium text-gray-900 leading-relaxed p-1">
|
||||
{{
|
||||
address.post_code && address.city
|
||||
? `${address.address}, ${address.post_code} ${address.city}`
|
||||
: address.address
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
|
||||
{{ address.description }}
|
||||
</p>
|
||||
</Card>
|
||||
<button
|
||||
v-if="edit"
|
||||
|
||||
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getEmails(person).length">
|
||||
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx">
|
||||
<div class="flex items-center justify-between mb-2" v-if="edit">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
|
||||
<div class="flex items-center justify-between" v-if="edit">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-if="email?.label"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
@@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed">
|
||||
<p class="font-medium text-gray-900 leading-relaxed">
|
||||
{{ email?.value || email?.email || email?.address || "-" }}
|
||||
</p>
|
||||
<p
|
||||
|
||||
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
|
||||
|
||||
<template>
|
||||
<Tabs v-model="activeTab" class="mt-2">
|
||||
<TabsList class="flex w-full bg-white gap-2 p-1">
|
||||
<TabsList class="flex flex-row flex-wrap bg-white gap-2 p-1">
|
||||
<TabsTrigger
|
||||
value="person"
|
||||
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
|
||||
@@ -384,6 +384,7 @@ const switchToTab = (tab) => {
|
||||
</TabsList>
|
||||
<TabsContent value="person" class="py-2">
|
||||
<PersonInfoPersonTab
|
||||
:is-client-case="clientCaseUuid ? true : false"
|
||||
:person="person"
|
||||
:edit="edit"
|
||||
:person-edit="personEdit"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
import { UserEditIcon } from "@/Utilities/Icons";
|
||||
import { Button } from "../ui/button";
|
||||
import { fmtDateDMY } from "@/Utilities/functions";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
isClientCase: { type: Boolean, default: false },
|
||||
edit: { type: Boolean, default: true },
|
||||
personEdit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit']);
|
||||
const emit = defineEmits(["edit"]);
|
||||
|
||||
const getMainAddress = (adresses) => {
|
||||
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
|
||||
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit');
|
||||
emit("edit");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -44,51 +46,126 @@ const handleEdit = () => {
|
||||
>
|
||||
<UserEditIcon size="md" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Primer ref.
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Naziv</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ person.full_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Davčna
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ person.tax_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Emšo</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
{{ person.social_security_number }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p>
|
||||
<div
|
||||
v-if="isClientCase"
|
||||
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"
|
||||
>
|
||||
<div
|
||||
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Naslov
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainAddress(person.addresses) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Telefon
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainPhone(person.phones) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Dat. rojstva
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ fmtDateDMY(person.birthday) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Naslov
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainAddress(person.addresses) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Telefon
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ getMainPhone(person.phones) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3"
|
||||
:class="[isClientCase ? 'md:grid-cols-2' : '']"
|
||||
>
|
||||
<div
|
||||
v-if="isClientCase"
|
||||
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||
Delodajalec
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ person.employer }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="md:col-span-full rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
:class="[isClientCase ? 'lg:col-span-1' : '']"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Opis</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ person.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
|
||||
import {
|
||||
CircleCheckBigIcon,
|
||||
CircleCheckIcon,
|
||||
EllipsisVertical,
|
||||
MessageSquare,
|
||||
MessageSquareText,
|
||||
} from "lucide-vue-next";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getPhones(person).length">
|
||||
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
@@ -79,8 +85,12 @@ const handleSms = (phone) => emit("sms", phone);
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
|
||||
<p class="font-medium leading-relaxed p-1 flex gap-1 items-center">
|
||||
{{ phone.nu }}
|
||||
<CircleCheckBigIcon color="#3e9392" size="20" v-if="phone.validated" />
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
|
||||
{{ phone.description }}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -301,27 +302,13 @@ const updateSmsFromSelection = async () => {
|
||||
const url = route("clientCase.sms.preview", {
|
||||
client_case: props.clientCaseUuid,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-TOKEN":
|
||||
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
|
||||
"",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_id: form.values.template_id,
|
||||
contract_uuid: form.values.contract_uuid || null,
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
const { data } = await axios.post(url, {
|
||||
template_id: form.values.template_id,
|
||||
contract_uuid: form.values.contract_uuid || null,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||
form.setFieldValue("message", data.content);
|
||||
return;
|
||||
}
|
||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||
form.setFieldValue("message", data.content);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and fallback
|
||||
|
||||
@@ -1,182 +1,205 @@
|
||||
<script setup>
|
||||
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
|
||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
||||
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import DatePicker from "../DatePicker.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
person: Object
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
person: Object,
|
||||
});
|
||||
|
||||
const processingUpdate = ref(false);
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
full_name: z.string().min(1, "Naziv je obvezen."),
|
||||
tax_number: z.string().optional(),
|
||||
social_security_number: z.string().optional(),
|
||||
birthday: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
employer: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
full_name: props.person?.full_name || '',
|
||||
tax_number: props.person?.tax_number || '',
|
||||
social_security_number: props.person?.social_security_number || '',
|
||||
description: props.person?.description || ''
|
||||
full_name: props.person?.full_name || "",
|
||||
tax_number: props.person?.tax_number || "",
|
||||
social_security_number: props.person?.social_security_number || "",
|
||||
birthday: props.person?.birthday || "",
|
||||
description: props.person?.description || "",
|
||||
employer: props.person?.employer || "",
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
setTimeout(() => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
full_name: props.person?.full_name || '',
|
||||
tax_number: props.person?.tax_number || '',
|
||||
social_security_number: props.person?.social_security_number || '',
|
||||
description: props.person?.description || ''
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
full_name: props.person?.full_name || "",
|
||||
tax_number: props.person?.tax_number || "",
|
||||
social_security_number: props.person?.social_security_number || "",
|
||||
birthday: props.person?.birthday || "",
|
||||
description: props.person?.description || "",
|
||||
employer: props.person?.employer || "",
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const updatePerson = async () => {
|
||||
processingUpdate.value = true;
|
||||
const { values } = form;
|
||||
processingUpdate.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.put(
|
||||
route('person.update', props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processingUpdate.value = false;
|
||||
close();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
router.put(route("person.update", props.person), values, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processingUpdate.value = false;
|
||||
close();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processingUpdate.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(() => {
|
||||
updatePerson();
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
onSubmit();
|
||||
}
|
||||
onSubmit();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<UpdateDialog
|
||||
:show="show"
|
||||
:title="`Posodobi ${person.full_name}`"
|
||||
confirm-text="Shrani"
|
||||
:processing="processingUpdate"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>
|
||||
Oseba
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<UpdateDialog
|
||||
:show="show"
|
||||
:title="`Posodobi ${person.full_name}`"
|
||||
confirm-text="Shrani"
|
||||
:processing="processingUpdate"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title> Oseba </template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="cfullname"
|
||||
type="text"
|
||||
placeholder="Naziv"
|
||||
autocomplete="full-name"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="cfullname"
|
||||
type="text"
|
||||
placeholder="Naziv"
|
||||
autocomplete="full-name"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="ctaxnumber"
|
||||
type="text"
|
||||
placeholder="Davčna številka"
|
||||
autocomplete="tax-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="ctaxnumber"
|
||||
type="text"
|
||||
placeholder="Davčna številka"
|
||||
autocomplete="tax-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem>
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="csocialSecurityNumber"
|
||||
type="text"
|
||||
placeholder="Matična / Emšo"
|
||||
autocomplete="social-security-number"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="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>
|
||||
@@ -1,178 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "@/Components/ui/sidebar";
|
||||
|
||||
import {
|
||||
AudioWaveform,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Command,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
Map,
|
||||
PieChart,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
} from "lucide-vue-next";
|
||||
import NavMain from "@/Components/app/ui/layout/NavMain.vue";
|
||||
import NavProjects from "@/Components/app/ui/layout/NavProjects.vue";
|
||||
import NavUser from "@/Components/app/ui/layout/NavUser.vue";
|
||||
import TeamSwitcher from "@/Components/app/ui/layout/TeamSwitcher.vue";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
collapsible: "icon",
|
||||
});
|
||||
|
||||
// This is sample data.
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: "Acme Inc",
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: "Enterprise",
|
||||
},
|
||||
{
|
||||
name: "Acme Corp.",
|
||||
logo: AudioWaveform,
|
||||
plan: "Startup",
|
||||
},
|
||||
{
|
||||
name: "Evil Corp.",
|
||||
logo: Command,
|
||||
plan: "Free",
|
||||
},
|
||||
],
|
||||
navMain: [
|
||||
{
|
||||
title: "Playground",
|
||||
url: "#",
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: "History",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Starred",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "#",
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
title: "Genesis",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Explorer",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Quantum",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
url: "#",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{
|
||||
title: "Introduction",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Get Started",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Tutorials",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Changelog",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "General",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Limits",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: "Design Engineering",
|
||||
url: "#",
|
||||
icon: Frame,
|
||||
},
|
||||
{
|
||||
name: "Sales & Marketing",
|
||||
url: "#",
|
||||
icon: PieChart,
|
||||
},
|
||||
{
|
||||
name: "Travel",
|
||||
url: "#",
|
||||
icon: Map,
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar v-bind="props">
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher :teams="data.teams" />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain :items="data.navMain" />
|
||||
<NavProjects :projects="data.projects" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser :user="data.user" />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
as-child
|
||||
:default-open="item.isActive"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<a :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { Folder, Forward, MoreHorizontal, Trash2 } from "lucide-vue-next";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in projects" :key="item.name">
|
||||
<SidebarMenuButton as-child>
|
||||
<a :href="item.url">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.name }}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuAction show-on-hover>
|
||||
<MoreHorizontal />
|
||||
<span class="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-48 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:align="isMobile ? 'end' : 'start'"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder class="text-muted-foreground" />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward class="text-muted-foreground" />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 class="text-muted-foreground" />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton class="text-sidebar-foreground/70">
|
||||
<MoreHorizontal class="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/Components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
import { ChevronsUpDown, Plus } from "lucide-vue-next";
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = defineProps<{
|
||||
teams: {
|
||||
name: string;
|
||||
logo: Component;
|
||||
plan: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
const activeTeam = ref(props.teams[0]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<component :is="activeTeam.logo" class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{{ activeTeam.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs">{{ activeTeam.plan }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="(team, index) in teams"
|
||||
:key="team.name"
|
||||
class="gap-2 p-2"
|
||||
@click="activeTeam = team"
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-sm border">
|
||||
<component :is="team.logo" class="size-3.5 shrink-0" />
|
||||
</div>
|
||||
{{ team.name }}
|
||||
<DropdownMenuShortcut>⌘{{ index + 1 }}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2">
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fieldVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
orientation: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
:data-orientation="orientation"
|
||||
:class="cn(fieldVariants({ orientation }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-content"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="field-description"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
errors: { type: Array, required: false },
|
||||
});
|
||||
|
||||
const content = computed(() => {
|
||||
if (!props.errors || props.errors.length === 0) return null;
|
||||
|
||||
if (props.errors.length === 1 && props.errors[0]?.message) {
|
||||
return props.errors[0].message;
|
||||
}
|
||||
|
||||
return props.errors.some((e) => e?.message) ? props.errors : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="$slots.default || content"
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
:class="cn('text-destructive text-sm font-normal', props.class)"
|
||||
>
|
||||
<slot v-if="$slots.default" />
|
||||
|
||||
<template v-else-if="typeof content === 'string'">
|
||||
{{ content }}
|
||||
</template>
|
||||
|
||||
<ul
|
||||
v-else-if="Array.isArray(content)"
|
||||
class="ml-4 flex list-disc flex-col gap-1"
|
||||
>
|
||||
<li v-for="(error, index) in content" :key="index">
|
||||
{{ error?.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-group"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from '@/Components/ui/label';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
|
||||
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
variant: { type: String, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</legend>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
:data-content="!!$slots.default"
|
||||
:class="
|
||||
cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Separator class="absolute inset-0 top-1/2" />
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-label"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export { default as Field } from "./Field.vue";
|
||||
export { default as FieldContent } from "./FieldContent.vue";
|
||||
export { default as FieldDescription } from "./FieldDescription.vue";
|
||||
export { default as FieldError } from "./FieldError.vue";
|
||||
export { default as FieldGroup } from "./FieldGroup.vue";
|
||||
export { default as FieldLabel } from "./FieldLabel.vue";
|
||||
export { default as FieldLegend } from "./FieldLegend.vue";
|
||||
export { default as FieldSeparator } from "./FieldSeparator.vue";
|
||||
export { default as FieldSet } from "./FieldSet.vue";
|
||||
export { default as FieldTitle } from "./FieldTitle.vue";
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ProgressIndicator, ProgressRoot } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Number, null], required: false, default: 0 },
|
||||
max: { type: Number, required: false },
|
||||
getValueLabel: { type: Function, required: false },
|
||||
getValueText: { type: Function, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProgressRoot
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ProgressIndicator
|
||||
class="h-full w-full flex-1 bg-primary transition-all"
|
||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Progress } from "./Progress.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([
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { Head, Link, router, usePage } from "@inertiajs/vue3";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faUserGroup,
|
||||
faShieldHalved,
|
||||
faArrowLeft,
|
||||
faFileWord,
|
||||
faBars,
|
||||
faGears,
|
||||
faKey,
|
||||
faEnvelope,
|
||||
faEnvelopeOpenText,
|
||||
faAt,
|
||||
faInbox,
|
||||
faFileLines,
|
||||
faMessage,
|
||||
faAddressBook,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
MenuIcon,
|
||||
ChevronDownIcon,
|
||||
ShieldCheckIcon,
|
||||
UsersIcon,
|
||||
KeyRoundIcon,
|
||||
Settings2Icon,
|
||||
FileTextIcon,
|
||||
MailOpenIcon,
|
||||
InboxIcon,
|
||||
AtSignIcon,
|
||||
BookUserIcon,
|
||||
MessageSquareIcon,
|
||||
ArrowLeftIcon,
|
||||
} from "lucide-vue-next";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import DropdownLink from "@/Components/DropdownLink.vue";
|
||||
import GlobalSearch from "@/Layouts/Partials/GlobalSearch.vue";
|
||||
@@ -29,25 +29,61 @@ import { Button } from "@/Components/ui/button";
|
||||
|
||||
const props = defineProps({ title: { type: String, default: "Administrator" } });
|
||||
|
||||
// Basic state reused (simplified vs AppLayout)
|
||||
// Collapsible sidebar state (persisted when user explicitly toggles)
|
||||
const sidebarCollapsed = ref(false);
|
||||
const hasSavedSidebarPref = ref(false);
|
||||
// Mobile off-canvas state
|
||||
const isMobile = ref(false);
|
||||
const mobileSidebarOpen = ref(false);
|
||||
function handleResize() {
|
||||
|
||||
function applyAutoCollapse() {
|
||||
if (typeof window === "undefined") return;
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
if (!isMobile.value) mobileSidebarOpen.value = false;
|
||||
sidebarCollapsed.value = isMobile.value; // auto collapse on small
|
||||
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
|
||||
sidebarCollapsed.value = isMobile.value;
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (typeof window !== "undefined") {
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
|
||||
}
|
||||
if (!hasSavedSidebarPref.value) applyAutoCollapse();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
try {
|
||||
const saved = localStorage.getItem("sidebarCollapsed");
|
||||
if (saved !== null) {
|
||||
hasSavedSidebarPref.value = true;
|
||||
sidebarCollapsed.value = saved === "1";
|
||||
} else {
|
||||
applyAutoCollapse();
|
||||
}
|
||||
} catch {}
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => window.removeEventListener("resize", handleResize));
|
||||
|
||||
watch(sidebarCollapsed, (v) => {
|
||||
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
|
||||
try {
|
||||
localStorage.setItem("sidebarCollapsed", v ? "1" : "0");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
if (isMobile.value) mobileSidebarOpen.value = !mobileSidebarOpen.value;
|
||||
else sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
hasSavedSidebarPref.value = true; // user explicitly chose
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
mobileSidebarOpen.value = !mobileSidebarOpen.value;
|
||||
}
|
||||
|
||||
function handleSidebarToggleClick() {
|
||||
if (isMobile.value) toggleMobileSidebar();
|
||||
else toggleSidebar();
|
||||
}
|
||||
|
||||
const logout = () => router.post(route("logout"));
|
||||
@@ -63,7 +99,7 @@ const navGroups = computed(() => [
|
||||
key: "admin.dashboard",
|
||||
label: "Pregled",
|
||||
route: "admin.index",
|
||||
icon: faShieldHalved,
|
||||
icon: ShieldCheckIcon,
|
||||
active: ["admin.index"],
|
||||
},
|
||||
],
|
||||
@@ -76,14 +112,14 @@ const navGroups = computed(() => [
|
||||
key: "admin.users",
|
||||
label: "Uporabniki",
|
||||
route: "admin.users.index",
|
||||
icon: faUserGroup,
|
||||
icon: UsersIcon,
|
||||
active: ["admin.users.index"],
|
||||
},
|
||||
{
|
||||
key: "admin.permissions.index",
|
||||
label: "Dovoljenja",
|
||||
route: "admin.permissions.index",
|
||||
icon: faKey,
|
||||
icon: KeyRoundIcon,
|
||||
active: ["admin.permissions.index", "admin.permissions.create"],
|
||||
},
|
||||
],
|
||||
@@ -96,14 +132,14 @@ const navGroups = computed(() => [
|
||||
key: "admin.document-settings.index",
|
||||
label: "Nastavitve dokumentov",
|
||||
route: "admin.document-settings.index",
|
||||
icon: faGears,
|
||||
icon: Settings2Icon,
|
||||
active: ["admin.document-settings.index"],
|
||||
},
|
||||
{
|
||||
key: "admin.document-templates.index",
|
||||
label: "Predloge dokumentov",
|
||||
route: "admin.document-templates.index",
|
||||
icon: faFileWord,
|
||||
icon: FileTextIcon,
|
||||
active: ["admin.document-templates.index"],
|
||||
},
|
||||
],
|
||||
@@ -116,7 +152,7 @@ const navGroups = computed(() => [
|
||||
key: "admin.email-templates.index",
|
||||
label: "Email predloge",
|
||||
route: "admin.email-templates.index",
|
||||
icon: faEnvelopeOpenText,
|
||||
icon: MailOpenIcon,
|
||||
active: [
|
||||
"admin.email-templates.index",
|
||||
"admin.email-templates.create",
|
||||
@@ -127,14 +163,14 @@ const navGroups = computed(() => [
|
||||
key: "admin.email-logs.index",
|
||||
label: "Email dnevniki",
|
||||
route: "admin.email-logs.index",
|
||||
icon: faInbox,
|
||||
icon: InboxIcon,
|
||||
active: ["admin.email-logs.index", "admin.email-logs.show"],
|
||||
},
|
||||
{
|
||||
key: "admin.mail-profiles.index",
|
||||
label: "Mail profili",
|
||||
route: "admin.mail-profiles.index",
|
||||
icon: faAt,
|
||||
icon: AtSignIcon,
|
||||
active: ["admin.mail-profiles.index"],
|
||||
},
|
||||
],
|
||||
@@ -147,7 +183,7 @@ const navGroups = computed(() => [
|
||||
key: "admin.sms-templates.index",
|
||||
label: "SMS predloge",
|
||||
route: "admin.sms-templates.index",
|
||||
icon: faFileLines,
|
||||
icon: FileTextIcon,
|
||||
active: [
|
||||
"admin.sms-templates.index",
|
||||
"admin.sms-templates.create",
|
||||
@@ -158,28 +194,28 @@ const navGroups = computed(() => [
|
||||
key: "admin.sms-logs.index",
|
||||
label: "SMS dnevniki",
|
||||
route: "admin.sms-logs.index",
|
||||
icon: faInbox,
|
||||
icon: InboxIcon,
|
||||
active: ["admin.sms-logs.index", "admin.sms-logs.show"],
|
||||
},
|
||||
{
|
||||
key: "admin.sms-senders.index",
|
||||
label: "SMS pošiljatelji",
|
||||
route: "admin.sms-senders.index",
|
||||
icon: faAddressBook,
|
||||
icon: BookUserIcon,
|
||||
active: ["admin.sms-senders.index"],
|
||||
},
|
||||
{
|
||||
key: "admin.sms-profiles.index",
|
||||
label: "SMS profili",
|
||||
route: "admin.sms-profiles.index",
|
||||
icon: faGears,
|
||||
icon: Settings2Icon,
|
||||
active: ["admin.sms-profiles.index"],
|
||||
},
|
||||
{
|
||||
key: "admin.packages.index",
|
||||
label: "SMS paketi",
|
||||
route: "admin.packages.index",
|
||||
icon: faMessage,
|
||||
icon: MessageSquareIcon,
|
||||
active: ["admin.packages.index", "admin.packages.show"],
|
||||
},
|
||||
],
|
||||
@@ -215,64 +251,64 @@ 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>
|
||||
<nav class="py-4 overflow-y-auto">
|
||||
<div v-for="group in navGroups" :key="group.key" class="mt-2 first:mt-0 px-2">
|
||||
<p
|
||||
v-if="!sidebarCollapsed"
|
||||
class="px-4 py-1.5 mb-1 mt-4 first:mt-0 text-[11px] font-semibold uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="item in group.items" :key="item.key">
|
||||
<Link
|
||||
:href="route(item.route)"
|
||||
:title="item.label"
|
||||
:class="[
|
||||
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
|
||||
isActive(item.active)
|
||||
? 'bg-primary-50 text-primary-700 font-medium shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
|
||||
]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="item.icon"
|
||||
<ul class="space-y-4 px-2">
|
||||
<li v-for="group in navGroups" :key="group.label">
|
||||
<div
|
||||
v-if="!sidebarCollapsed"
|
||||
class="px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/60"
|
||||
>
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="item in group.items" :key="item.key">
|
||||
<Link
|
||||
:href="route(item.route)"
|
||||
:title="item.label"
|
||||
:class="[
|
||||
'w-5 h-5 flex-shrink-0 transition-colors',
|
||||
isActive(item.active) ? 'text-primary-600' : 'text-gray-500',
|
||||
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
|
||||
isActive(item.active)
|
||||
? 'bg-sidebar-primary/15 text-sidebar-primary font-medium shadow-sm'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
]"
|
||||
/>
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="truncate transition-opacity"
|
||||
:class="{ 'font-medium': isActive(item.active) }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="w-5 h-5 shrink-0 transition-colors"
|
||||
/>
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="truncate transition-opacity"
|
||||
:class="{ 'font-medium': isActive(item.active) }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 space-y-2 px-4">
|
||||
<Link
|
||||
:href="route('dashboard')"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 hover:underline flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
class="text-xs hover:underline flex items-center gap-2 px-3 py-2 rounded-lg text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-all duration-150"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faArrowLeft" class="w-3.5 h-3.5" />
|
||||
<ArrowLeftIcon size="18" />
|
||||
<span v-if="!sidebarCollapsed">Nazaj na aplikacijo</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -287,10 +323,11 @@ function isActive(patterns) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="toggleSidebar"
|
||||
@click="handleSidebarToggleClick"
|
||||
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faBars" class="w-5 h-5" />
|
||||
<MenuIcon />
|
||||
</Button>
|
||||
<h1 class="text-base font-semibold text-gray-900 hidden sm:block">
|
||||
{{ title }}
|
||||
@@ -314,27 +351,9 @@ function isActive(patterns) {
|
||||
</button>
|
||||
|
||||
<span v-else class="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
type="button"
|
||||
class="gap-2"
|
||||
>
|
||||
<Button variant="outline" size="default" type="button" class="gap-2">
|
||||
{{ $page.props.auth.user.name }}
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
@@ -358,10 +377,7 @@ function isActive(patterns) {
|
||||
</div>
|
||||
|
||||
<!-- Page Heading -->
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="bg-white border-b border-gray-200 shadow-sm"
|
||||
>
|
||||
<header v-if="$slots.header" class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
||||
<Breadcrumbs
|
||||
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
||||
@@ -376,10 +392,7 @@ function isActive(patterns) {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<GlobalSearch :open="false" />
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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,32 @@
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import { ref, watch } from "vue";
|
||||
import {
|
||||
Settings2Icon,
|
||||
SaveIcon,
|
||||
CheckCircle2Icon,
|
||||
AlertCircleIcon,
|
||||
} from "lucide-vue-next";
|
||||
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 { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import { Alert, AlertDescription } from "@/Components/ui/alert";
|
||||
|
||||
const props = defineProps({ settings: Object, defaults: Object });
|
||||
const form = useForm({
|
||||
@@ -70,92 +96,147 @@ function submit() {
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Nastavitve dokumentov">
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<h1 class="text-2xl font-semibold">Nastavitve dokumentov</h1>
|
||||
<form @submit.prevent="submit" class="space-y-6 bg-white p-6 border rounded">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">Vzorec imena</span>
|
||||
<input v-model="form.file_name_pattern" class="border rounded px-3 py-2" />
|
||||
<span class="text-xs text-gray-500"
|
||||
>Podprti placeholderji: {slug} {version} {generation.date}
|
||||
{generation.timestamp}</span
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<span v-if="form.errors.file_name_pattern" class="text-xs text-rose-600">{{
|
||||
form.errors.file_name_pattern
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">Privzeti datum format</span>
|
||||
<input v-model="form.date_format" class="border rounded px-3 py-2" />
|
||||
<span class="text-xs text-gray-500">npr. Y-m-d ali d.m.Y</span>
|
||||
<span v-if="form.errors.date_format" class="text-xs text-rose-600">{{
|
||||
form.errors.date_format
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">Politika nerešenih</span>
|
||||
<select v-model="form.unresolved_policy" class="border rounded px-3 py-2">
|
||||
<option value="fail">Fail</option>
|
||||
<option value="blank">Blank</option>
|
||||
<option value="keep">Keep</option>
|
||||
</select>
|
||||
<span v-if="form.errors.unresolved_policy" class="text-xs text-rose-600">{{
|
||||
form.errors.unresolved_policy
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.preview_enabled"
|
||||
true-value="1"
|
||||
false-value="0"
|
||||
/>
|
||||
<span class="text-sm font-medium">Omogoči predoglede</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">Whitelist (JSON)</span>
|
||||
<textarea
|
||||
v-model="form.whitelist"
|
||||
rows="8"
|
||||
class="font-mono text-xs border rounded p-2"
|
||||
></textarea>
|
||||
<span v-if="whitelistError" class="text-xs text-rose-600">{{
|
||||
whitelistError
|
||||
}}</span>
|
||||
<span v-else-if="form.errors.whitelist" class="text-xs text-rose-600">{{
|
||||
form.errors.whitelist
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">Date formats override (JSON)</span>
|
||||
<textarea
|
||||
v-model="form.date_formats"
|
||||
rows="8"
|
||||
class="font-mono text-xs border rounded p-2"
|
||||
></textarea>
|
||||
<span class="text-xs text-gray-500"
|
||||
>Primer: {"contract.start_date":"d.m.Y"}</span
|
||||
>
|
||||
<span v-if="dateFormatsError" class="text-xs text-rose-600">{{
|
||||
dateFormatsError
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{{ form.processing ? "Shranjevanje..." : "Shrani" }}
|
||||
</button>
|
||||
<span v-if="form.wasSuccessful" class="text-sm text-emerald-600"
|
||||
>Shranjeno</span
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<Settings2Icon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Nastavitve dokumentov</CardTitle>
|
||||
<CardDescription>
|
||||
Konfiguracija generiranja dokumentov, vzorcev imen in formatiranja
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<!-- Basic Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Osnovne nastavitve</h3>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="file_name_pattern">Vzorec imena datoteke</Label>
|
||||
<Input
|
||||
id="file_name_pattern"
|
||||
v-model="form.file_name_pattern"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Podprti placeholderji: {slug} {version} {generation.date}
|
||||
{generation.timestamp}
|
||||
</p>
|
||||
<p
|
||||
v-if="form.errors.file_name_pattern"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ form.errors.file_name_pattern }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="date_format">Privzeti datum format</Label>
|
||||
<Input
|
||||
id="date_format"
|
||||
v-model="form.date_format"
|
||||
placeholder="Y-m-d"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">npr. Y-m-d ali d.m.Y</p>
|
||||
<p v-if="form.errors.date_format" class="text-sm text-destructive">
|
||||
{{ form.errors.date_format }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="unresolved_policy">Politika nerešenih spremenljivk</Label>
|
||||
<Select v-model="form.unresolved_policy">
|
||||
<SelectTrigger id="unresolved_policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fail">Fail (napaka)</SelectItem>
|
||||
<SelectItem value="blank">Blank (prazno)</SelectItem>
|
||||
<SelectItem value="keep">Keep (obdrži)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
v-if="form.errors.unresolved_policy"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ form.errors.unresolved_policy }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-8">
|
||||
<Switch id="preview_enabled" v-model="form.preview_enabled" />
|
||||
<Label for="preview_enabled" class="cursor-pointer">
|
||||
Omogoči predoglede dokumentov
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON Configuration -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Napišrana konfiguracija (JSON)</h3>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="whitelist">Whitelist (dovoljeni tokeni)</Label>
|
||||
<Textarea
|
||||
id="whitelist"
|
||||
v-model="form.whitelist"
|
||||
rows="10"
|
||||
class="font-mono text-xs"
|
||||
placeholder='{"entity": ["column1", "column2"]}'
|
||||
/>
|
||||
<Alert v-if="whitelistError" variant="destructive" class="py-2">
|
||||
<AlertCircleIcon class="h-4 w-4" />
|
||||
<AlertDescription>{{ whitelistError }}</AlertDescription>
|
||||
</Alert>
|
||||
<p v-else-if="form.errors.whitelist" class="text-sm text-destructive">
|
||||
{{ form.errors.whitelist }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="date_formats">Date formats override (JSON)</Label>
|
||||
<Textarea
|
||||
id="date_formats"
|
||||
v-model="form.date_formats"
|
||||
rows="10"
|
||||
class="font-mono text-xs"
|
||||
placeholder='{"contract.start_date": "d.m.Y"}'
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Primer: {"contract.start_date":"d.m.Y"}
|
||||
</p>
|
||||
<Alert v-if="dateFormatsError" variant="destructive" class="py-2">
|
||||
<AlertCircleIcon class="h-4 w-4" />
|
||||
<AlertDescription>{{ dateFormatsError }}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<Button :disabled="form.processing" type="submit">
|
||||
<SaveIcon class="h-4 w-4 mr-2" />
|
||||
{{ form.processing ? "Shranjevanje..." : "Shrani nastavitve" }}
|
||||
</Button>
|
||||
<Alert v-if="form.wasSuccessful" class="py-2 px-3 w-auto">
|
||||
<CheckCircle2Icon class="h-4 w-4 text-green-600" />
|
||||
<AlertDescription class="text-green-600">Shranjeno</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup>
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { Settings2Icon, FileTextIcon, CalendarIcon, AlertTriangleIcon, ListIcon, PencilIcon } from 'lucide-vue-next'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card'
|
||||
import { Badge } from '@/Components/ui/badge'
|
||||
import { Button } from '@/Components/ui/button'
|
||||
import { Separator } from '@/Components/ui/separator'
|
||||
|
||||
const props = defineProps({
|
||||
config: Object,
|
||||
})
|
||||
@@ -7,26 +14,95 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Nastavitve dokumentov">
|
||||
<h1 class="text-2xl font-semibold mb-4">Nastavitve dokumentov</h1>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-white rounded border">
|
||||
<h2 class="font-medium mb-2">Privzeti vzorci</h2>
|
||||
<p class="text-sm text-gray-600">Ime datoteke: <code class="px-1 bg-gray-100 rounded">{{ config.file_name_pattern }}</code></p>
|
||||
<p class="text-sm text-gray-600">Format datuma: <code class="px-1 bg-gray-100 rounded">{{ config.date_format }}</code></p>
|
||||
<p class="text-sm text-gray-600">Politika nerešenih: <code class="px-1 bg-gray-100 rounded">{{ config.unresolved_policy }}</code></p>
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded border">
|
||||
<h2 class="font-medium mb-2">Dovoljeni tokeni (whitelist)</h2>
|
||||
<div v-for="(cols, entity) in config.whitelist" :key="entity" class="mb-3">
|
||||
<div class="text-sm font-semibold">{{ entity }}</div>
|
||||
<div class="text-xs text-gray-600" v-if="cols.length">{{ cols.join(', ') }}</div>
|
||||
<div class="text-xs text-gray-400" v-else>(brez specifičnih stolpcev)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded border">
|
||||
<h2 class="font-medium mb-2">Uredi (prihaja)</h2>
|
||||
<p class="text-xs text-gray-500">Za urejanje bo dodan obrazec. Trenutno spremembe izvedite v <code>config/documents.php</code>.</p>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
|
||||
<Settings2Icon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Nastavitve dokumentov</CardTitle>
|
||||
<CardDescription>
|
||||
Sistemska konfiguracija za generiranje in upravljanje dokumentov
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" as-child>
|
||||
<Link :href="route('admin.document-settings.edit')">
|
||||
<PencilIcon class="h-4 w-4 mr-2" />
|
||||
Uredi
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Basic Configuration -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
<FileTextIcon class="h-4 w-4" />
|
||||
Privzeti vzorci
|
||||
</h3>
|
||||
<div class="grid gap-3">
|
||||
<div class="flex items-start justify-between p-3 rounded-lg border bg-muted/50">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Ime datoteke</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Vzorec za generiranje imen</p>
|
||||
</div>
|
||||
<Badge variant="secondary" class="font-mono text-xs">
|
||||
{{ config.file_name_pattern }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-start justify-between p-3 rounded-lg border bg-muted/50">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Format datuma</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Privzeto formatiranje datumov</p>
|
||||
</div>
|
||||
<Badge variant="secondary" class="font-mono text-xs">
|
||||
{{ config.date_format }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-start justify-between p-3 rounded-lg border bg-muted/50">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Politika nerešenih</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Vedenje pri nerešenih spremenljivkah</p>
|
||||
</div>
|
||||
<Badge variant="secondary" class="font-mono text-xs">
|
||||
{{ config.unresolved_policy }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Whitelist Configuration -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
<ListIcon class="h-4 w-4" />
|
||||
Dovoljeni tokeni (whitelist)
|
||||
</h3>
|
||||
<div class="grid gap-3">
|
||||
<Card v-for="(cols, entity) in config.whitelist" :key="entity" class="border-muted">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-sm font-mono">{{ entity }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="cols.length" class="flex flex-wrap gap-1.5">
|
||||
<Badge v-for="col in cols" :key="col" variant="outline" class="text-xs font-mono">
|
||||
{{ col }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted-foreground italic">
|
||||
(brez specifičnih stolpcev)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,327 +1,352 @@
|
||||
<template>
|
||||
<AdminLayout title="Uredi predlogo">
|
||||
<div class="mb-6 flex flex-col lg:flex-row lg:items-start gap-6">
|
||||
<div class="flex-1 min-w-[320px]">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{{ template.name }}</h1>
|
||||
<p class="text-xs text-gray-500 mt-1 flex flex-wrap gap-3">
|
||||
<span class="inline-flex items-center gap-1"
|
||||
><span class="text-gray-400">Slug:</span
|
||||
><span class="font-medium">{{ template.slug }}</span></span
|
||||
>
|
||||
<span class="inline-flex items-center gap-1"
|
||||
><span class="text-gray-400">Verzija:</span
|
||||
><span class="font-medium">v{{ template.version }}</span></span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
:class="template.active ? 'text-emerald-600' : 'text-gray-400'"
|
||||
><span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="template.active ? 'bg-emerald-500' : 'bg-gray-300'"
|
||||
/>
|
||||
{{ template.active ? "Aktivna" : "Neaktivna" }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="toggleActive" class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
:class="[btnBase, template.active ? btnWarn : btnOutline]"
|
||||
:disabled="toggleForm.processing"
|
||||
>
|
||||
<span v-if="toggleForm.processing">...</span>
|
||||
<span v-else>{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}</span>
|
||||
</button>
|
||||
<Link
|
||||
:href="route('admin.document-templates.show', template.id)"
|
||||
:class="[btnBase, btnOutline]"
|
||||
>Ogled</Link
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[320px] space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
|
||||
<FileTextIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{{ template.name }}</CardTitle>
|
||||
<CardDescription class="flex flex-wrap gap-3 mt-1">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span>Slug:</span>
|
||||
<Badge variant="secondary" class="text-xs">{{ template.slug }}</Badge>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span>Verzija:</span>
|
||||
<Badge variant="secondary" class="text-xs">v{{ template.version }}</Badge>
|
||||
</span>
|
||||
<Badge :variant="template.active ? 'default' : 'outline'" class="text-xs">
|
||||
{{ template.active ? "Aktivna" : "Neaktivna" }}
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form @submit.prevent="toggleActive">
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="template.active ? 'destructive' : 'default'"
|
||||
size="sm"
|
||||
:disabled="toggleForm.processing"
|
||||
>
|
||||
<PowerOffIcon v-if="template.active" class="h-4 w-4 mr-2" />
|
||||
<Power v-else class="h-4 w-4 mr-2" />
|
||||
{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}
|
||||
</Button>
|
||||
</form>
|
||||
<Button size="sm" variant="outline" as-child>
|
||||
<Link :href="route('admin.document-templates.show', template.id)">
|
||||
<EyeIcon class="h-4 w-4 mr-2" />
|
||||
Ogled
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-8">
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<!-- Osnovno -->
|
||||
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Osnovne nastavitve
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600"
|
||||
>Izlazna datoteka (pattern)</span
|
||||
>
|
||||
<input
|
||||
v-model="form.output_filename_pattern"
|
||||
type="text"
|
||||
class="input input-bordered w-full input-sm"
|
||||
placeholder="POVRACILO_{contract.reference}"
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings2Icon class="h-4 w-4" />
|
||||
<CardTitle class="text-base">Osnovne nastavitve</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="output_filename_pattern">Izlazna datoteka (pattern)</Label>
|
||||
<Input
|
||||
id="output_filename_pattern"
|
||||
v-model="form.output_filename_pattern"
|
||||
placeholder="POVRACILO_{contract.reference}"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tokens npr. {contract.reference}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="date_format">Privzeti format datuma</Label>
|
||||
<Input
|
||||
id="date_format"
|
||||
v-model="form.date_format"
|
||||
placeholder="d.m.Y"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="fail_on_unresolved"
|
||||
:default-value="form.fail_on_unresolved"
|
||||
@update:model-value="(val) => (form.fail_on_unresolved = val)"
|
||||
/>
|
||||
<span class="text-[11px] text-gray-500"
|
||||
>Tokens npr. {contract.reference}</span
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600"
|
||||
>Privzeti format datuma</span
|
||||
>
|
||||
<input
|
||||
v-model="form.date_format"
|
||||
type="text"
|
||||
class="input input-bordered w-full input-sm"
|
||||
placeholder="d.m.Y"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs font-medium text-gray-600">
|
||||
<input
|
||||
id="fail_on_unresolved"
|
||||
type="checkbox"
|
||||
v-model="form.fail_on_unresolved"
|
||||
class="checkbox checkbox-xs"
|
||||
/>
|
||||
<span>Prekini če token ni rešen (fail on unresolved)</span>
|
||||
</label>
|
||||
</div>
|
||||
<Label for="fail_on_unresolved" class="cursor-pointer font-normal">
|
||||
Prekini če token ni rešen (fail on unresolved)
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Formatiranje -->
|
||||
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Formatiranje
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-3 gap-5">
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Decimalna mesta</span>
|
||||
<input
|
||||
v-model.number="form.number_decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
class="input input-bordered w-full input-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Decimalni separator</span>
|
||||
<input
|
||||
v-model="form.decimal_separator"
|
||||
type="text"
|
||||
maxlength="2"
|
||||
class="input input-bordered w-full input-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Tisocice separator</span>
|
||||
<input
|
||||
v-model="form.thousands_separator"
|
||||
type="text"
|
||||
maxlength="2"
|
||||
class="input input-bordered w-full input-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Znak valute</span>
|
||||
<input
|
||||
v-model="form.currency_symbol"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
class="input input-bordered w-full input-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Pozicija valute</span>
|
||||
<select
|
||||
v-model="form.currency_position"
|
||||
class="select select-bordered select-sm w-full"
|
||||
>
|
||||
<option :value="null">(privzeto)</option>
|
||||
<option value="before">Pred</option>
|
||||
<option value="after">Za</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-2 space-y-0 pt-6 text-xs font-medium text-gray-600"
|
||||
>
|
||||
<input
|
||||
id="currency_space"
|
||||
type="checkbox"
|
||||
v-model="form.currency_space"
|
||||
class="checkbox checkbox-xs"
|
||||
/>
|
||||
<span>Presledek pred/za valuto</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<FileTextIcon class="h-4 w-4" />
|
||||
<CardTitle class="text-base">Formatiranje</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="number_decimals">Decimalna mesta</Label>
|
||||
<Input
|
||||
id="number_decimals"
|
||||
v-model.number="form.number_decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="decimal_separator">Decimalni separator</Label>
|
||||
<Input
|
||||
id="decimal_separator"
|
||||
v-model="form.decimal_separator"
|
||||
maxlength="2"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="thousands_separator">Tisočice separator</Label>
|
||||
<Input
|
||||
id="thousands_separator"
|
||||
v-model="form.thousands_separator"
|
||||
maxlength="2"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="currency_symbol">Znak valute</Label>
|
||||
<Input
|
||||
id="currency_symbol"
|
||||
v-model="form.currency_symbol"
|
||||
maxlength="8"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="currency_position">Pozicija valute</Label>
|
||||
<Select v-model="form.currency_position">
|
||||
<SelectTrigger id="currency_position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">(privzeto)</SelectItem>
|
||||
<SelectItem value="before">Pred</SelectItem>
|
||||
<SelectItem value="after">Za</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-8">
|
||||
<Checkbox
|
||||
id="currency_space"
|
||||
:default-value="form.currency_space"
|
||||
@update:model-value="(val) => (form.currency_space = val)"
|
||||
/>
|
||||
<Label for="currency_space" class="cursor-pointer font-normal">
|
||||
Presledek pred/za valuto
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Aktivnost -->
|
||||
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Aktivnost
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Akcija</span>
|
||||
<select
|
||||
v-model="form.action_id"
|
||||
class="select select-bordered select-sm w-full"
|
||||
@change="handleActionChange"
|
||||
>
|
||||
<option :value="null">(brez)</option>
|
||||
<option v-for="a in actions" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="space-y-1 block">
|
||||
<span class="text-xs font-medium text-gray-600">Odločitev</span>
|
||||
<select
|
||||
v-model="form.decision_id"
|
||||
class="select select-bordered select-sm w-full"
|
||||
:disabled="!currentActionDecisions.length"
|
||||
>
|
||||
<option :value="null">(brez)</option>
|
||||
<option v-for="d in currentActionDecisions" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="space-y-1 md:col-span-2 block">
|
||||
<span class="text-xs font-medium text-gray-600"
|
||||
>Predloga opombe aktivnosti</span
|
||||
>
|
||||
<textarea
|
||||
v-model="form.activity_note_template"
|
||||
rows="3"
|
||||
class="textarea textarea-bordered w-full text-xs"
|
||||
placeholder="Besedilo aktivnosti..."
|
||||
/>
|
||||
<span class="text-[11px] text-gray-500"
|
||||
>Tokeni npr. {contract.reference}</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<ActivityIcon class="h-4 w-4" />
|
||||
<CardTitle class="text-base">Aktivnost</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="action_id">Akcija</Label>
|
||||
<Select v-model="form.action_id" @update:model-value="handleActionChange">
|
||||
<SelectTrigger id="action_id">
|
||||
<SelectValue placeholder="(brez)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">(brez)</SelectItem>
|
||||
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="decision_id">Odločitev</Label>
|
||||
<Select v-model="form.decision_id" :disabled="!currentActionDecisions.length">
|
||||
<SelectTrigger id="decision_id">
|
||||
<SelectValue placeholder="(brez)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">(brez)</SelectItem>
|
||||
<SelectItem v-for="d in currentActionDecisions" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="activity_note_template">Predloga opombe aktivnosti</Label>
|
||||
<Textarea
|
||||
id="activity_note_template"
|
||||
v-model="form.activity_note_template"
|
||||
rows="3"
|
||||
placeholder="Besedilo aktivnosti..."
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tokeni npr. {contract.reference}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Custom tokens defaults -->
|
||||
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Custom tokens (privzete vrednosti)
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" :class="[btnBase, btnOutline]" @click="addCustomDefault">
|
||||
Dodaj vrstico
|
||||
</button>
|
||||
<CodeIcon class="h-4 w-4" />
|
||||
<CardTitle class="text-base">Custom tokens (privzete vrednosti)</CardTitle>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<Button type="button" variant="outline" size="sm" @click="addCustomDefault">
|
||||
Dodaj vrstico
|
||||
</Button>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div
|
||||
v-for="(row, idx) in customRows"
|
||||
:key="idx"
|
||||
class="grid grid-cols-12 items-center gap-2"
|
||||
class="grid grid-cols-12 items-start gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="row.key"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full col-span-4"
|
||||
placeholder="custom ključ (npr. order_id)"
|
||||
/>
|
||||
<template v-if="row.type === 'text'">
|
||||
<textarea
|
||||
<div class="col-span-4 space-y-1">
|
||||
<Input
|
||||
v-model="row.key"
|
||||
placeholder="custom ključ (npr. order_id)"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-5 space-y-1">
|
||||
<Textarea
|
||||
v-if="row.type === 'text'"
|
||||
v-model="row.value"
|
||||
rows="3"
|
||||
class="textarea textarea-bordered w-full text-xs col-span-5"
|
||||
placeholder="privzeta vrednost"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
<Input
|
||||
v-else
|
||||
v-model="row.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full col-span-5"
|
||||
placeholder="privzeta vrednost"
|
||||
/>
|
||||
</template>
|
||||
<select v-model="row.type" class="select select-bordered select-sm w-full col-span-2">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="date">date</option>
|
||||
<option value="text">text</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-ghost btn-xs col-span-1" @click="removeCustomDefault(idx)">✕</button>
|
||||
</div>
|
||||
<div class="col-span-2 space-y-1">
|
||||
<Select v-model="row.type">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="date">date</SelectItem>
|
||||
<SelectItem value="text">text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-span-1 flex items-center pt-2">
|
||||
<Button type="button" variant="ghost" size="icon" @click="removeCustomDefault(idx)">
|
||||
<XIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-gray-500">
|
||||
Uporabite v predlogi kot <code v-pre>{{custom.your_key}}</code>. Manjkajoče vrednosti se privzeto izpraznijo.
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Uporabite v predlogi kot <code class="px-1 py-0.5 bg-muted rounded text-xs" v-pre>{{custom.your_key}}</code>. Manjkajoče vrednosti se privzeto izpraznijo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
:class="[btnBase, btnPrimary]"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<span v-if="form.processing">Shranjevanje…</span>
|
||||
<span v-else>Shrani spremembe</span>
|
||||
</button>
|
||||
<Link
|
||||
:href="route('admin.document-templates.show', template.id)"
|
||||
:class="[btnBase, btnOutline]"
|
||||
>Prekliči</Link
|
||||
>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<SaveIcon class="h-4 w-4 mr-2" />
|
||||
{{ form.processing ? "Shranjevanje…" : "Shrani spremembe" }}
|
||||
</Button>
|
||||
<Button variant="outline" as-child>
|
||||
<Link :href="route('admin.document-templates.show', template.id)">
|
||||
<XIcon class="h-4 w-4 mr-2" />
|
||||
Prekliči
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Side meta panel -->
|
||||
<aside class="w-full lg:w-72 space-y-6">
|
||||
<div class="bg-white border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<h3 class="text-xs font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Meta
|
||||
</h3>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<li>
|
||||
<span class="text-gray-400">Velikost:</span>
|
||||
<span class="font-medium"
|
||||
>{{ (template.file_size / 1024).toFixed(1) }} KB</span
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">Meta podatki</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Velikost:</span>
|
||||
<Badge variant="secondary">{{ (template.file_size / 1024).toFixed(1) }} KB</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Hash:</span>
|
||||
<code class="text-xs">{{ template.file_hash?.substring(0, 12) }}…</code>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Engine:</span>
|
||||
<Badge variant="outline">{{ template.engine }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Button variant="outline" size="sm" class="w-full" as-child>
|
||||
<a :href="'/storage/' + template.file_path" target="_blank">
|
||||
Prenesi izvorni DOCX
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card v-if="template.tokens?.length">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">Tokens</CardTitle>
|
||||
<CardDescription>{{ template.tokens.length }} tokenov</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-wrap gap-1.5 max-h-48 overflow-auto">
|
||||
<Badge
|
||||
v-for="t in template.tokens"
|
||||
:key="t"
|
||||
variant="secondary"
|
||||
class="font-mono text-xs"
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Hash:</span>
|
||||
<span class="font-mono">{{ template.file_hash?.substring(0, 12) }}…</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Engine:</span>
|
||||
<span class="font-medium">{{ template.engine }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
:href="'/storage/' + template.file_path"
|
||||
target="_blank"
|
||||
class="text-[11px] inline-flex items-center gap-1 text-indigo-600 hover:underline"
|
||||
>Prenesi izvorni DOCX →</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="template.tokens?.length"
|
||||
class="bg-white border rounded-lg shadow-sm p-4"
|
||||
>
|
||||
<h3 class="text-xs font-semibold tracking-wide text-gray-600 uppercase mb-2">
|
||||
Tokens ({{ template.tokens.length }})
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1.5 max-h-48 overflow-auto pr-1">
|
||||
<span
|
||||
v-for="t in template.tokens"
|
||||
:key="t"
|
||||
class="px-1.5 py-0.5 bg-gray-100 rounded text-[11px] font-mono"
|
||||
>{{ t }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{{ t }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
@@ -331,6 +356,16 @@
|
||||
import { computed, reactive } from "vue";
|
||||
import { useForm, Link, router } from "@inertiajs/vue3";
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Settings2Icon, FileTextIcon, ActivityIcon, CodeIcon, SaveIcon, XIcon, EyeIcon, Power, PowerOffIcon } from "lucide-vue-next";
|
||||
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 { Textarea } from "@/Components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
|
||||
// Button style utility classes
|
||||
const btnBase =
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, useForm } from "@inertiajs/vue3";
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
UploadIcon,
|
||||
FileTextIcon,
|
||||
Power,
|
||||
PowerOffIcon,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Progress } from "@/Components/ui/progress";
|
||||
|
||||
const props = defineProps({
|
||||
templates: { type: Array, default: () => [] },
|
||||
@@ -9,7 +37,7 @@ const props = defineProps({
|
||||
|
||||
// Upload form state
|
||||
const uploadForm = useForm({ name: "", slug: "", file: null });
|
||||
const selectedSlug = ref("");
|
||||
const selectedSlug = ref(null);
|
||||
const uniqueSlugs = computed(() => {
|
||||
const s = new Set(props.templates.map((t) => t.slug));
|
||||
return Array.from(s).sort();
|
||||
@@ -60,186 +88,164 @@ const groups = computed(() => {
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Dokumentne predloge">
|
||||
<div class="mb-8 space-y-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Header & Upload -->
|
||||
<div class="flex flex-col xl:flex-row xl:items-start gap-6">
|
||||
<div class="flex-1 min-w-[280px]">
|
||||
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<span>Dokumentne predloge</span>
|
||||
<span
|
||||
class="text-xs font-medium bg-gray-200 text-gray-600 px-2 py-0.5 rounded"
|
||||
>{{ groups.length }} skupin</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 mt-1 max-w-prose">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Dokumentne predloge</h1>
|
||||
<Badge variant="secondary">{{ groups.length }} skupin</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground max-w-prose">
|
||||
Upravljaj verzije DOCX predlog. Naloži novo verzijo obstoječega sluga ali
|
||||
ustvari popolnoma novo predlogo.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
@submit.prevent="submitUpload"
|
||||
class="flex-1 bg-white/70 backdrop-blur border rounded-lg shadow-sm p-4 flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<span class="i-lucide-upload-cloud w-4 h-4" /> Nova / nova verzija
|
||||
</h2>
|
||||
<div
|
||||
v-if="uploadForm.progress"
|
||||
class="w-40 h-1 bg-gray-200 rounded overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-indigo-500 transition-all"
|
||||
:style="{ width: uploadForm.progress.percentage + '%' }"
|
||||
<Card class="flex-1">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<UploadIcon class="h-4 w-4" />
|
||||
Nova / nova verzija
|
||||
</CardTitle>
|
||||
<Progress
|
||||
v-if="uploadForm.progress"
|
||||
:model-value="uploadForm.progress.percentage"
|
||||
class="w-40 h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-5 gap-3 text-xs">
|
||||
<div class="md:col-span-1">
|
||||
<label class="block font-medium mb-1">Obstoječi slug</label>
|
||||
<select
|
||||
v-model="selectedSlug"
|
||||
class="select select-bordered select-sm w-full"
|
||||
>
|
||||
<option value="">(nov)</option>
|
||||
<option v-for="s in uniqueSlugs" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-1">
|
||||
<label class="block font-medium mb-1">Nov slug</label>
|
||||
<input
|
||||
v-model="uploadForm.slug"
|
||||
:disabled="!!selectedSlug"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="opomin"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-1">
|
||||
<label class="block font-medium mb-1">Naziv</label>
|
||||
<input
|
||||
v-model="uploadForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Ime predloge"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-end">
|
||||
<label class="w-full">
|
||||
<input
|
||||
id="docx-upload-input"
|
||||
@change="handleFile"
|
||||
type="file"
|
||||
accept=".docx"
|
||||
class="file-input file-input-bordered file-input-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span class="text-[11px] text-gray-500" v-if="!uploadForm.file"
|
||||
>Izberi DOCX datoteko…</span
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm btn-primary"
|
||||
:disabled="
|
||||
uploadForm.processing ||
|
||||
!uploadForm.file ||
|
||||
(!uploadForm.slug && !selectedSlug)
|
||||
"
|
||||
>
|
||||
<span v-if="uploadForm.processing">Nalaganje…</span>
|
||||
<span v-else>Shrani verzijo</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="uploadForm.errors.file" class="text-rose-600 text-xs">
|
||||
{{ uploadForm.errors.file }}
|
||||
</div>
|
||||
</form>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="submitUpload" class="space-y-4">
|
||||
<div class="grid md:grid-cols-5 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="existing_slug">Obstoječi slug</Label>
|
||||
<Select v-model="selectedSlug">
|
||||
<SelectTrigger id="existing_slug">
|
||||
<SelectValue placeholder="(nov)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="s in uniqueSlugs" :key="s" :value="s">{{
|
||||
s
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new_slug">Nov slug</Label>
|
||||
<Input
|
||||
id="new_slug"
|
||||
v-model="uploadForm.slug"
|
||||
:disabled="!!selectedSlug"
|
||||
placeholder="opomin"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="template_name">Naziv</Label>
|
||||
<Input
|
||||
id="template_name"
|
||||
v-model="uploadForm.name"
|
||||
placeholder="Ime predloge"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2 space-y-2">
|
||||
<Label for="docx-upload-input">DOCX datoteka</Label>
|
||||
<Input
|
||||
id="docx-upload-input"
|
||||
@change="handleFile"
|
||||
type="file"
|
||||
accept=".docx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<span class="text-xs text-muted-foreground" v-if="!uploadForm.file">
|
||||
Izberi DOCX datoteko…
|
||||
</span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:disabled="
|
||||
uploadForm.processing ||
|
||||
!uploadForm.file ||
|
||||
(!uploadForm.slug && !selectedSlug)
|
||||
"
|
||||
>
|
||||
<UploadIcon class="h-4 w-4 mr-2" />
|
||||
{{ uploadForm.processing ? "Nalaganje…" : "Shrani verzijo" }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="uploadForm.errors.file" class="text-sm text-destructive">
|
||||
{{ uploadForm.errors.file }}
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div v-if="groups.length" class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="g in groups"
|
||||
:key="g.slug"
|
||||
class="group relative flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 border-b bg-gradient-to-r from-gray-50 to-white flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-medium text-sm leading-5 truncate">{{ g.name }}</h3>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 mt-1 text-[11px] text-gray-500"
|
||||
>
|
||||
<span class="px-1.5 py-0.5 bg-gray-100 rounded">{{ g.slug }}</span>
|
||||
<span>Zadnja: v{{ g.versions[0].version }}</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:class="
|
||||
g.versions.filter((v) => v.active).length
|
||||
? 'text-emerald-600'
|
||||
: 'text-gray-400'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="
|
||||
g.versions.filter((v) => v.active).length
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-gray-300'
|
||||
<Card v-for="g in groups" :key="g.slug">
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base truncate">{{ g.name }}</CardTitle>
|
||||
<CardDescription class="flex flex-wrap items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" class="text-xs">{{ g.slug }}</Badge>
|
||||
<span class="text-xs">Zadnja: v{{ g.versions[0].version }}</span>
|
||||
<Badge
|
||||
:variant="
|
||||
g.versions.filter((v) => v.active).length ? 'default' : 'outline'
|
||||
"
|
||||
/>
|
||||
{{ g.versions.filter((v) => v.active).length }} aktivnih
|
||||
</span>
|
||||
class="text-xs"
|
||||
>
|
||||
{{ g.versions.filter((v) => v.active).length }} aktivnih
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" as-child>
|
||||
<Link :href="route('admin.document-templates.show', g.versions[0].id)">
|
||||
Detalji
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Link
|
||||
:href="route('admin.document-templates.show', g.versions[0].id)"
|
||||
class="text-xs text-indigo-600 hover:underline whitespace-nowrap mt-1"
|
||||
>Detalji</Link
|
||||
>
|
||||
</div>
|
||||
<div class="p-3 flex-1 flex flex-col gap-2">
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="v in g.versions" :key="v.id" class="flex items-center gap-1">
|
||||
<Link
|
||||
:href="route('admin.document-templates.edit', v.id)"
|
||||
class="px-2 py-0.5 rounded-md border text-[11px] font-medium transition-colors"
|
||||
:class="
|
||||
v.active
|
||||
? 'border-emerald-500/60 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
|
||||
"
|
||||
>v{{ v.version }}</Link
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="v.active ? 'default' : 'outline'"
|
||||
class="h-7 px-2 text-xs"
|
||||
as-child
|
||||
>
|
||||
<button
|
||||
<Link :href="route('admin.document-templates.edit', v.id)">
|
||||
v{{ v.version }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
:variant="v.active ? 'destructive' : 'outline'"
|
||||
class="h-7 w-7"
|
||||
@click="toggle(v.id)"
|
||||
class="rounded-md border px-1.5 py-0.5 text-[10px] font-medium transition-colors"
|
||||
:class="
|
||||
v.active
|
||||
? 'bg-amber-500 border-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'
|
||||
"
|
||||
>
|
||||
{{ v.active ? "✕" : "✓" }}
|
||||
</button>
|
||||
<XIcon v-if="v.active" class="h-3 w-3" />
|
||||
<CheckIcon v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto pt-2 border-t flex justify-end">
|
||||
<Link
|
||||
:href="route('admin.document-templates.edit', g.versions[0].id)"
|
||||
class="text-[11px] text-indigo-600 hover:underline"
|
||||
>Uredi zadnjo verzijo →</Link
|
||||
>
|
||||
<div class="pt-2 border-t flex justify-end">
|
||||
<Button size="sm" variant="link" class="h-auto p-0" as-child>
|
||||
<Link :href="route('admin.document-templates.edit', g.versions[0].id)">
|
||||
Uredi zadnjo verziju →
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500">Ni predlog.</p>
|
||||
<p v-else class="text-sm text-muted-foreground">Ni predlog.</p>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@@ -2,231 +2,294 @@
|
||||
<AdminLayout title="Predloga">
|
||||
<div class="flex flex-col lg:flex-row gap-6 items-start">
|
||||
<div class="flex-1 min-w-[320px] space-y-6">
|
||||
<div class="bg-white border rounded-lg shadow-sm p-5 flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{{ template.name }}</h1>
|
||||
<p class="text-xs text-gray-500 mt-1 flex flex-wrap gap-3">
|
||||
<span class="inline-flex items-center gap-1"
|
||||
><span class="text-gray-400">Slug:</span
|
||||
><span class="font-medium">{{ template.slug }}</span></span
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1"
|
||||
><span class="text-gray-400">Verzija:</span
|
||||
><span class="font-medium">v{{ template.version }}</span></span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
:class="template.active ? 'text-emerald-600' : 'text-gray-400'"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="template.active ? 'bg-emerald-500' : 'bg-gray-300'"
|
||||
/>
|
||||
{{ template.active ? "Aktivna" : "Neaktivna" }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="toggleActive" class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
:class="[btnBase, template.active ? btnWarn : btnOutline]"
|
||||
:disabled="toggleForm.processing"
|
||||
>
|
||||
<span v-if="toggleForm.processing">...</span>
|
||||
<span v-else>{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}</span>
|
||||
</button>
|
||||
<Link
|
||||
:href="route('admin.document-templates.edit', template.id)"
|
||||
:class="[btnBase, btnPrimary]"
|
||||
>Uredi</Link
|
||||
>
|
||||
<Link
|
||||
:href="route('admin.document-templates.index')"
|
||||
:class="[btnBase, btnOutline]"
|
||||
>Nazaj</Link
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-6 text-xs">
|
||||
<div class="space-y-2">
|
||||
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
|
||||
Datoteka
|
||||
</h3>
|
||||
<ul class="space-y-1 text-gray-600">
|
||||
<li>
|
||||
<span class="text-gray-400">Velikost:</span>
|
||||
<span class="font-medium"
|
||||
>{{ (template.file_size / 1024).toFixed(1) }} KB</span
|
||||
<FileTextIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{{ template.name }}</CardTitle>
|
||||
<CardDescription class="flex flex-wrap gap-3 mt-1">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span>Slug:</span>
|
||||
<Badge variant="secondary" class="text-xs">{{
|
||||
template.slug
|
||||
}}</Badge>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span>Verzija:</span>
|
||||
<Badge variant="secondary" class="text-xs"
|
||||
>v{{ template.version }}</Badge
|
||||
>
|
||||
</span>
|
||||
<Badge
|
||||
:variant="template.active ? 'default' : 'outline'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ template.active ? "Aktivna" : "Neaktivna" }}
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form @submit.prevent="toggleActive">
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="template.active ? 'destructive' : 'default'"
|
||||
size="sm"
|
||||
:disabled="toggleForm.processing"
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Hash:</span>
|
||||
<span class="font-mono"
|
||||
>{{ template.file_hash?.substring(0, 12) }}…</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Engine:</span>
|
||||
<span class="font-medium">{{ template.engine }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
:href="'/storage/' + template.file_path"
|
||||
target="_blank"
|
||||
class="text-[11px] inline-flex items-center gap-1 text-indigo-600 hover:underline"
|
||||
>Prenesi DOCX →</a
|
||||
<PowerOffIcon v-if="template.active" class="h-4 w-4 mr-2" />
|
||||
<Power v-else class="h-4 w-4 mr-2" />
|
||||
{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}
|
||||
</Button>
|
||||
</form>
|
||||
<Button size="sm" as-child>
|
||||
<Link :href="route('admin.document-templates.edit', template.id)">
|
||||
<PencilIcon class="h-4 w-4 mr-2" />
|
||||
Uredi
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" as-child>
|
||||
<Link :href="route('admin.document-templates.index')">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid md:grid-cols-3 gap-6 text-sm">
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold flex items-center gap-2">
|
||||
<FileTextIcon class="h-4 w-4" />
|
||||
Datoteka
|
||||
</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Velikost:</span>
|
||||
<Badge variant="secondary"
|
||||
>{{ (template.file_size / 1024).toFixed(1) }} KB</Badge
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Hash:</span>
|
||||
<code class="text-xs"
|
||||
>{{ template.file_hash?.substring(0, 12) }}…</code
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Engine:</span>
|
||||
<Badge variant="outline">{{ template.engine }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="w-full" as-child>
|
||||
<a :href="'/storage/' + template.file_path" target="_blank">
|
||||
<DownloadIcon class="h-4 w-4 mr-2" />
|
||||
Prenesi DOCX
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold flex items-center gap-2">
|
||||
<HashIcon class="h-4 w-4" />
|
||||
Formatiranje
|
||||
</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Datum:</span>
|
||||
<code class="text-xs">{{
|
||||
template.settings?.date_format || "d.m.Y"
|
||||
}}</code>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Decimalna mesta:</span>
|
||||
<span>{{ template.settings?.number_decimals ?? "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Separators:</span>
|
||||
<code class="text-xs">
|
||||
{{ template.settings?.decimal_separator || "." }} /
|
||||
{{ template.settings?.thousands_separator || " " }}
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Valuta:</span>
|
||||
<span class="text-xs">
|
||||
{{ template.settings?.currency_symbol || "€" }} ({{
|
||||
template.settings?.currency_position || "before"
|
||||
}})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold">Aktivnost</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Akcija:</span>
|
||||
<Badge variant="outline">{{ template.action?.name || "-" }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Odločitev:</span>
|
||||
<Badge variant="outline">{{ template.decision?.name || "-" }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Fail unresolved:</span>
|
||||
<Badge
|
||||
:variant="
|
||||
template.settings?.fail_on_unresolved
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
"
|
||||
>
|
||||
{{ template.settings?.fail_on_unresolved ? "DA" : "NE" }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="template.settings?.activity_note_template">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Predloga opombe aktivnosti</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre
|
||||
class="bg-muted p-3 rounded border text-xs leading-relaxed whitespace-pre-wrap font-mono"
|
||||
>{{ template.settings.activity_note_template }}</pre
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="template.tokens?.length">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle class="text-base">Tokens</CardTitle>
|
||||
<CardDescription>{{ template.tokens.length }} tokenov</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="expandedTokens = !expandedTokens"
|
||||
>
|
||||
{{ expandedTokens ? "Skrij" : "Prikaži vse" }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
|
||||
Formatiranje
|
||||
</h3>
|
||||
<ul class="space-y-1 text-gray-600">
|
||||
<li>
|
||||
<span class="text-gray-400">Datum:</span>
|
||||
{{ template.settings?.date_format || "d.m.Y" }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Decimalna mesta:</span>
|
||||
{{ template.settings?.number_decimals ?? "-" }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Separators:</span>
|
||||
{{ template.settings?.decimal_separator || "." }} /
|
||||
{{ template.settings?.thousands_separator || " " }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Valuta:</span>
|
||||
{{ template.settings?.currency_symbol || "€" }} ({{
|
||||
template.settings?.currency_position || "before"
|
||||
}})
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
|
||||
Aktivnost
|
||||
</h3>
|
||||
<ul class="space-y-1 text-gray-600">
|
||||
<li>
|
||||
<span class="text-gray-400">Akcija:</span>
|
||||
{{ template.action?.name || "-" }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Odločitev:</span>
|
||||
{{ template.decision?.name || "-" }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-400">Fail unresolved:</span>
|
||||
{{ template.settings?.fail_on_unresolved ? "DA" : "NE" }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="template.settings?.activity_note_template"
|
||||
class="bg-white border rounded-lg shadow-sm p-5 space-y-2 text-xs"
|
||||
>
|
||||
<h2 class="uppercase font-semibold tracking-wide text-gray-600">
|
||||
Predloga opombe aktivnosti
|
||||
</h2>
|
||||
<pre
|
||||
class="bg-gray-50 p-3 rounded border text-[11px] leading-relaxed whitespace-pre-wrap"
|
||||
>{{ template.settings.activity_note_template }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="template.tokens?.length"
|
||||
class="bg-white border rounded-lg shadow-sm p-5"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="uppercase font-semibold tracking-wide text-gray-600 text-xs">
|
||||
Tokens ({{ template.tokens.length }})
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="expandedTokens = !expandedTokens"
|
||||
class="text-[11px] text-indigo-600 hover:underline"
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
class="flex flex-wrap gap-1.5 overflow-auto"
|
||||
:class="!expandedTokens && 'max-h-32'"
|
||||
>
|
||||
{{ expandedTokens ? "Skrij" : "Prikaži vse" }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-1.5 max-h-56 overflow-auto pr-1"
|
||||
:class="!expandedTokens && 'max-h-32'"
|
||||
>
|
||||
<span
|
||||
v-for="t in template.tokens"
|
||||
:key="t"
|
||||
class="px-1.5 py-0.5 bg-gray-100 rounded text-[11px] font-mono"
|
||||
>{{ t }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
v-for="t in template.tokens"
|
||||
:key="t"
|
||||
variant="secondary"
|
||||
class="font-mono text-xs"
|
||||
>
|
||||
{{ t }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<aside class="w-full lg:w-72 space-y-6">
|
||||
<div class="bg-white border rounded-lg shadow-sm p-4 space-y-3 text-xs">
|
||||
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
|
||||
Hitra dejanja
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Link
|
||||
:href="route('admin.document-templates.edit', template.id)"
|
||||
:class="[btnBase, btnPrimary]"
|
||||
>Uredi nastavitve</Link
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">Hitra dejanja</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-2">
|
||||
<Button size="sm" as-child>
|
||||
<Link :href="route('admin.document-templates.edit', template.id)">
|
||||
<PencilIcon class="h-4 w-4 mr-2" />
|
||||
Uredi nastavitve
|
||||
</Link>
|
||||
</Button>
|
||||
<form @submit.prevent="toggleActive">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
:class="[btnBase, template.active ? btnWarn : btnOutline]"
|
||||
size="sm"
|
||||
:variant="template.active ? 'destructive' : 'default'"
|
||||
:disabled="toggleForm.processing"
|
||||
class="w-full"
|
||||
>
|
||||
<PowerOffIcon v-if="template.active" class="h-4 w-4 mr-2" />
|
||||
<Power v-else class="h-4 w-4 mr-2" />
|
||||
{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<form @submit.prevent="rescan">
|
||||
<button type="submit" :class="[btnBase, btnOutline]" :disabled="rescanForm.processing">
|
||||
<span v-if="rescanForm.processing">Pregledujem…</span>
|
||||
<span v-else>Ponovno preglej tokene</span>
|
||||
</button>
|
||||
</form>
|
||||
<Link
|
||||
:href="route('admin.document-templates.index')"
|
||||
:class="[btnBase, btnOutline]"
|
||||
>Vse predloge</Link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white border rounded-lg shadow-sm p-4 space-y-2 text-[11px] text-gray-600"
|
||||
>
|
||||
<h3 class="uppercase font-semibold tracking-wide text-gray-600 text-xs">
|
||||
Opombe
|
||||
</h3>
|
||||
<p>
|
||||
Uporabi to stran za hiter pregled meta podatkov predloge ter njenih tokenov.
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="rescan">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="rescanForm.processing"
|
||||
class="w-full"
|
||||
>
|
||||
<RefreshCwIcon class="h-4 w-4 mr-2" />
|
||||
{{ rescanForm.processing ? "Pregledujem…" : "Ponovno preglej tokene" }}
|
||||
</Button>
|
||||
</form>
|
||||
<Button size="sm" variant="outline" as-child>
|
||||
<Link :href="route('admin.document-templates.index')">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Vse predloge
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">Opombe</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground">
|
||||
<p>
|
||||
Uporabi to stran za hiter pregled meta podatkov predloge ter njenih tokenov.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { Link, useForm } from "@inertiajs/vue3";
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import {
|
||||
FileTextIcon,
|
||||
Power,
|
||||
PowerOffIcon,
|
||||
PencilIcon,
|
||||
ArrowLeftIcon,
|
||||
RefreshCwIcon,
|
||||
DownloadIcon,
|
||||
HashIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
|
||||
// Button style utility classes
|
||||
const btnBase =
|
||||
"inline-flex items-center justify-center gap-1 rounded-md border text-xs font-medium px-3 py-1.5 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const btnPrimary = "bg-indigo-600 border-indigo-600 text-white hover:bg-indigo-500";
|
||||
const btnOutline = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50";
|
||||
const btnWarn = "bg-amber-500 border-amber-500 text-white hover:bg-amber-400";
|
||||
const expandedTokens = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
template: Object,
|
||||
|
||||
@@ -1,104 +1,259 @@
|
||||
<script setup>
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { Head, Link, router } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Head, Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { MailIcon, FilterIcon, ExternalLinkIcon } from "lucide-vue-next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
|
||||
const props = defineProps({
|
||||
logs: Object,
|
||||
templates: Array,
|
||||
filters: Object,
|
||||
})
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
status: props.filters?.status || '',
|
||||
to: props.filters?.to || '',
|
||||
subject: props.filters?.subject || '',
|
||||
template_id: props.filters?.template_id || '',
|
||||
date_from: props.filters?.date_from || '',
|
||||
date_to: props.filters?.date_to || '',
|
||||
})
|
||||
status: props.filters?.status || "",
|
||||
to: props.filters?.to || "",
|
||||
subject: props.filters?.subject || "",
|
||||
template_id: props.filters?.template_id || "",
|
||||
date_from: props.filters?.date_from || "",
|
||||
date_to: props.filters?.date_to || "",
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
router.get(route('admin.email-logs.index'), {
|
||||
...form.value,
|
||||
}, { preserveState: true, preserveScroll: true })
|
||||
router.get(
|
||||
route("admin.email-logs.index"),
|
||||
{
|
||||
...form.value,
|
||||
},
|
||||
{ preserveState: true, preserveScroll: true }
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusVariant(status) {
|
||||
if (status === "sent") return "default";
|
||||
if (status === "queued" || status === "sending") return "secondary";
|
||||
if (status === "failed") return "destructive";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "created_at",
|
||||
label: "Datum",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: "to_email",
|
||||
label: "Prejemnik",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "Zadeva",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: "template",
|
||||
label: "Predloga",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: "duration_ms",
|
||||
label: "Trajanje",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Dejanja",
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Email Logs">
|
||||
<Head title="Email Logs" />
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-semibold text-gray-800">Email Logs</h1>
|
||||
<div class="mt-3 grid grid-cols-1 md:grid-cols-6 gap-2">
|
||||
<select v-model="form.status" class="input">
|
||||
<option value="">All statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="sending">Sending</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="deferred">Deferred</option>
|
||||
</select>
|
||||
<input v-model="form.to" placeholder="To email" class="input" />
|
||||
<input v-model="form.subject" placeholder="Subject" class="input" />
|
||||
<select v-model="form.template_id" class="input">
|
||||
<option value="">All templates</option>
|
||||
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<input v-model="form.date_from" type="date" class="input" />
|
||||
<input v-model="form.date_to" type="date" class="input" />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button @click="applyFilters" class="px-3 py-1.5 text-xs rounded border bg-gray-50 hover:bg-gray-100">Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<MailIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Email Logs</CardTitle>
|
||||
<CardDescription>
|
||||
Pregled vseh poslanih in čakajočih e-poštnih sporočil
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="applyFilters" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="status">Status</Label>
|
||||
<Select v-model="form.status">
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">All statuses</SelectItem>
|
||||
<SelectItem value="queued">Queued</SelectItem>
|
||||
<SelectItem value="sending">Sending</SelectItem>
|
||||
<SelectItem value="sent">Sent</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="bounced">Bounced</SelectItem>
|
||||
<SelectItem value="deferred">Deferred</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="to">To email</Label>
|
||||
<Input id="to" v-model="form.to" placeholder="prejemnik@email.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="subject">Subject</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
v-model="form.subject"
|
||||
placeholder="Iskanje po zadevi"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="template">Template</Label>
|
||||
<Select v-model="form.template_id">
|
||||
<SelectTrigger id="template">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">All templates</SelectItem>
|
||||
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">{{
|
||||
t.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="date_from">Od datuma</Label>
|
||||
<Input id="date_from" v-model="form.date_from" type="date" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="date_to">Do datuma</Label>
|
||||
<Input id="date_to" v-model="form.date_to" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
<FilterIcon class="h-4 w-4 mr-2" />
|
||||
Filtriraj
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm">
|
||||
<div class="overflow-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="text-left p-2">Date</th>
|
||||
<th class="text-left p-2">Status</th>
|
||||
<th class="text-left p-2">To</th>
|
||||
<th class="text-left p-2">Subject</th>
|
||||
<th class="text-left p-2">Template</th>
|
||||
<th class="text-left p-2">Duration</th>
|
||||
<th class="text-left p-2">\#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs.data" :key="log.id" class="border-t">
|
||||
<td class="p-2 whitespace-nowrap">{{ new Date(log.created_at).toLocaleString() }}</td>
|
||||
<td class="p-2"><span class="inline-flex items-center px-2 py-0.5 rounded text-xs border" :class="{
|
||||
'bg-green-50 text-green-700 border-green-200': log.status === 'sent',
|
||||
'bg-amber-50 text-amber-700 border-amber-200': log.status === 'queued' || log.status === 'sending',
|
||||
'bg-red-50 text-red-700 border-red-200': log.status === 'failed',
|
||||
}">{{ log.status }}</span></td>
|
||||
<td class="p-2 truncate max-w-[220px]">
|
||||
{{ log.to_email || (Array.isArray(log.to_recipients) && log.to_recipients.length ? log.to_recipients.join(', ') : '-') }}
|
||||
</td>
|
||||
<td class="p-2 truncate max-w-[320px]">{{ log.subject }}</td>
|
||||
<td class="p-2 truncate max-w-[220px]">{{ log.template?.name || '-' }}</td>
|
||||
<td class="p-2">{{ log.duration_ms ? log.duration_ms + ' ms' : '-' }}</td>
|
||||
<td class="p-2"><Link :href="route('admin.email-logs.show', log.id)" class="text-indigo-600 hover:underline">Open</Link></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-2 border-t text-xs text-gray-600 flex items-center justify-between">
|
||||
<div>Showing {{ logs.from }}-{{ logs.to }} of {{ logs.total }}</div>
|
||||
<div class="flex gap-2">
|
||||
<Link v-for="link in logs.links" :key="link.url || link.label" :href="link.url || '#'" :class="['px-2 py-1 rounded border text-xs', { 'bg-indigo-600 text-white border-indigo-600': link.active, 'pointer-events-none opacity-50': !link.url } ]" v-html="link.label" />
|
||||
</div>
|
||||
</div>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<MailIcon size="18" />
|
||||
<CardTitle class="uppercase">Uvozi</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<DataTableNew2
|
||||
:columns="columns"
|
||||
:data="logs.data"
|
||||
:meta="logs"
|
||||
:show-toolbar="false"
|
||||
:show-pagination="true"
|
||||
route-name="admin.email-logs.index"
|
||||
:preserve-state="true"
|
||||
:preserve-scroll="true"
|
||||
empty-text="Ni e-poštnih dnevnikov"
|
||||
empty-description="Začnite z ustvarjanjem e-poštnih sporočil"
|
||||
>
|
||||
<template #cell-created_at="{ value }">
|
||||
<div class="whitespace-nowrap text-sm">
|
||||
{{ new Date(value).toLocaleString() }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="getStatusVariant(value)">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #cell-to_email="{ row }">
|
||||
<div class="max-w-55 truncate">
|
||||
{{
|
||||
row.to_email ||
|
||||
(Array.isArray(row.to_recipients) && row.to_recipients.length
|
||||
? row.to_recipients.join(", ")
|
||||
: "-")
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-subject="{ value }">
|
||||
<div class="max-w-[320px] truncate">{{ value }}</div>
|
||||
</template>
|
||||
|
||||
<template #cell-template="{ row }">
|
||||
<div class="max-w-55 truncate">{{ row.template?.name || "-" }}</div>
|
||||
</template>
|
||||
|
||||
<template #cell-duration_ms="{ value }">
|
||||
<span v-if="value" class="text-xs text-muted-foreground">
|
||||
{{ value }} ms
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<Button size="sm" variant="ghost" as-child>
|
||||
<Link :href="route('admin.email-logs.show', row.id)">
|
||||
<ExternalLinkIcon class="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</AppCard>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input { width: 100%; border-radius: 0.375rem; border: 1px solid #d1d5db; padding: 0.5rem 0.75rem; font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.input:focus { outline: 2px solid transparent; outline-offset: 2px; border-color: #6366f1; box-shadow: 0 0 0 1px #6366f1; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup>
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { Head, Link } from '@inertiajs/vue3'
|
||||
import { MailIcon, ArrowLeftIcon, ClockIcon, UserIcon, AlertCircleIcon, FileTextIcon, CodeIcon } from 'lucide-vue-next'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card'
|
||||
import { Button } from '@/Components/ui/button'
|
||||
import { Badge } from '@/Components/ui/badge'
|
||||
import { Separator } from '@/Components/ui/separator'
|
||||
|
||||
const props = defineProps({
|
||||
log: Object,
|
||||
@@ -11,50 +16,124 @@ const props = defineProps({
|
||||
<AdminLayout title="Email Log">
|
||||
<Head title="Email Log" />
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link :href="route('admin.email-logs.index')" class="text-sm text-gray-600 hover:text-gray-800">Back</Link>
|
||||
<h1 class="text-xl font-semibold text-gray-800">Email Log #{{ props.log.id }}</h1>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">Created: {{ new Date(props.log.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
|
||||
<MailIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Email Log #{{ props.log.id }}</CardTitle>
|
||||
<CardDescription>
|
||||
Ustvarjeno: {{ new Date(props.log.created_at).toLocaleString() }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" as-child>
|
||||
<Link :href="route('admin.email-logs.index')">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4 space-y-2">
|
||||
<div class="text-sm"><span class="font-semibold">Status:</span> {{ props.log.status }}</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-semibold">To:</span>
|
||||
<template v-if="props.log.to_email">
|
||||
{{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="Array.isArray(props.log.to_recipients) && props.log.to_recipients.length">
|
||||
{{ props.log.to_recipients.join(', ') }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-sm"><span class="font-semibold">Subject:</span> {{ props.log.subject }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Template:</span> {{ props.log.template?.name || '-' }}</div>
|
||||
<!-- Message ID removed per request -->
|
||||
<div class="text-sm"><span class="font-semibold">Attempts:</span> {{ props.log.attempt }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Duration:</span> {{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}</div>
|
||||
<div v-if="props.log.error_message" class="text-sm text-red-700"><span class="font-semibold">Error:</span> {{ props.log.error_message }}</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Podrobnosti</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Status:</span>
|
||||
<Badge
|
||||
:variant="
|
||||
props.log.status === 'sent' ? 'default' :
|
||||
props.log.status === 'queued' || props.log.status === 'sending' ? 'secondary' :
|
||||
props.log.status === 'failed' ? 'destructive' : 'outline'
|
||||
"
|
||||
>
|
||||
{{ props.log.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium flex items-center gap-2">
|
||||
<UserIcon class="h-4 w-4" />
|
||||
Prejemnik
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<template v-if="props.log.to_email">
|
||||
{{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="Array.isArray(props.log.to_recipients) && props.log.to_recipients.length">
|
||||
{{ props.log.to_recipients.join(', ') }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium">Zadeva</div>
|
||||
<div class="text-sm text-muted-foreground">{{ props.log.subject }}</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Predloga:</span>
|
||||
<Badge variant="outline">{{ props.log.template?.name || '-' }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Poskusi:</span>
|
||||
<Badge variant="secondary">{{ props.log.attempt }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<ClockIcon class="h-4 w-4" />
|
||||
Trajanje:
|
||||
</span>
|
||||
<span class="text-sm">{{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}</span>
|
||||
</div>
|
||||
<div v-if="props.log.error_message" class="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircleIcon class="h-4 w-4 text-destructive mt-0.5" />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-destructive">Napaka</div>
|
||||
<div class="text-xs text-destructive/80 mt-1">{{ props.log.error_message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<FileTextIcon class="h-4 w-4" />
|
||||
Besedilo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre class="text-xs whitespace-pre-wrap break-words bg-muted p-3 rounded-md">{{ props.log.body?.body_text || '' }}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4">
|
||||
<div class="label">Text</div>
|
||||
<pre class="text-xs whitespace-pre-wrap break-words">{{ props.log.body?.body_text || '' }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4">
|
||||
<div class="label">HTML</div>
|
||||
<iframe :srcdoc="props.log.body?.body_html || ''" class="w-full h-[480px] border rounded bg-white"></iframe>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base flex items-center gap-2">
|
||||
<CodeIcon class="h-4 w-4" />
|
||||
HTML vsebina
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<iframe :srcdoc="props.log.body?.body_html || ''" class="w-full h-[480px] border rounded-md bg-white"></iframe>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label { display:block; font-size: 0.7rem; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:#6b7280; margin-bottom:0.25rem; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Head, Link } from "@inertiajs/vue3";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faPlus, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, MailIcon } from 'lucide-vue-next';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
const props = defineProps({
|
||||
templates: { type: Array, default: () => [] },
|
||||
@@ -19,50 +22,84 @@ function destroyTemplate(tpl) {
|
||||
<template>
|
||||
<AdminLayout title="Email predloge">
|
||||
<Head title="Email predloge" />
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800">Email predloge</h1>
|
||||
<Link
|
||||
:href="route('admin.email-templates.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nova predloga
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Ime</th>
|
||||
<th class="px-3 py-2 text-left">Ključ</th>
|
||||
<th class="px-3 py-2 text-left">Entities</th>
|
||||
<th class="px-3 py-2 text-left">Aktivno</th>
|
||||
<th class="px-3 py-2 text-left">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in templates" :key="t.id" class="border-t last:border-b hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-medium text-gray-800">{{ t.name }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ t.key }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ (t.entity_types || []).join(', ') }}</td>
|
||||
<td class="px-3 py-2">{{ t.active ? 'da' : 'ne' }}</td>
|
||||
<td class="px-3 py-2 flex items-center gap-2">
|
||||
<Link
|
||||
:href="route('admin.email-templates.edit', t.id)"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-600 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPenToSquare" class="w-3.5 h-3.5" /> Uredi
|
||||
</Link>
|
||||
<button
|
||||
@click="destroyTemplate(t)"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-rose-700 border-rose-300 bg-rose-50 hover:bg-rose-100"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="w-3.5 h-3.5" /> Izbriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
|
||||
<MailIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Email predloge</CardTitle>
|
||||
<CardDescription>
|
||||
Upravljanje predlog za e-poštna sporočila
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button as-child>
|
||||
<Link :href="route('admin.email-templates.create')">
|
||||
<PlusIcon class="h-4 w-4 mr-2" />
|
||||
Nova predloga
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ime</TableHead>
|
||||
<TableHead>Ključ</TableHead>
|
||||
<TableHead>Entitete</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead class="text-right">Akcije</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="t in templates" :key="t.id">
|
||||
<TableCell class="font-medium">{{ t.name }}</TableCell>
|
||||
<TableCell>
|
||||
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">{{ t.key }}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-for="entity in (t.entity_types || [])"
|
||||
:key="entity"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ entity }}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="t.active ? 'default' : 'secondary'">
|
||||
{{ t.active ? 'Aktivno' : 'Neaktivno' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" as-child>
|
||||
<Link :href="route('admin.email-templates.edit', t.id)">
|
||||
<PencilIcon class="h-4 w-4 mr-1" />
|
||||
Uredi
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
@click="destroyTemplate(t)"
|
||||
>
|
||||
<Trash2Icon class="h-4 w-4 mr-1" />
|
||||
Izbriši
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import {
|
||||
faUserGroup,
|
||||
faKey,
|
||||
faGears,
|
||||
faFileWord,
|
||||
faEnvelopeOpenText,
|
||||
faInbox,
|
||||
faAt,
|
||||
faAddressBook,
|
||||
faFileLines,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
UsersIcon,
|
||||
KeyRoundIcon,
|
||||
Settings2Icon,
|
||||
FileTextIcon,
|
||||
MailOpenIcon,
|
||||
InboxIcon,
|
||||
AtSignIcon,
|
||||
BookUserIcon,
|
||||
MessageSquareIcon,
|
||||
ArrowRightIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
@@ -22,13 +30,13 @@ const cards = [
|
||||
title: "Uporabniki",
|
||||
description: "Upravljanje uporabnikov in njihovih vlog",
|
||||
route: "admin.users.index",
|
||||
icon: faUserGroup,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
title: "Novo dovoljenje",
|
||||
description: "Dodaj in konfiguriraj novo dovoljenje",
|
||||
route: "admin.permissions.create",
|
||||
icon: faKey,
|
||||
icon: KeyRoundIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -39,13 +47,13 @@ const cards = [
|
||||
title: "Nastavitve dokumentov",
|
||||
description: "Privzete sistemske nastavitve za dokumente",
|
||||
route: "admin.document-settings.index",
|
||||
icon: faGears,
|
||||
icon: Settings2Icon,
|
||||
},
|
||||
{
|
||||
title: "Predloge dokumentov",
|
||||
description: "Upravljanje in verzioniranje DOCX predlog",
|
||||
route: "admin.document-templates.index",
|
||||
icon: faFileWord,
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -56,19 +64,19 @@ const cards = [
|
||||
title: "Email predloge",
|
||||
description: "Upravljanje HTML / tekst email predlog",
|
||||
route: "admin.email-templates.index",
|
||||
icon: faEnvelopeOpenText,
|
||||
icon: MailOpenIcon,
|
||||
},
|
||||
{
|
||||
title: "Email dnevniki",
|
||||
description: "Pregled poslanih emailov in statusov",
|
||||
route: "admin.email-logs.index",
|
||||
icon: faInbox,
|
||||
icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
title: "Mail profili",
|
||||
description: "SMTP profili, nastavitve in testiranje povezave",
|
||||
route: "admin.mail-profiles.index",
|
||||
icon: faAt,
|
||||
icon: AtSignIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -79,31 +87,31 @@ const cards = [
|
||||
title: "SMS profili",
|
||||
description: "Nastavitve SMS profilov, testno pošiljanje in stanje kreditov",
|
||||
route: "admin.sms-profiles.index",
|
||||
icon: faGears,
|
||||
icon: Settings2Icon,
|
||||
},
|
||||
{
|
||||
title: "SMS pošiljatelji",
|
||||
description: "Upravljanje nazivov pošiljateljev (Sender ID) za SMS profile",
|
||||
route: "admin.sms-senders.index",
|
||||
icon: faAddressBook,
|
||||
icon: BookUserIcon,
|
||||
},
|
||||
{
|
||||
title: "SMS predloge",
|
||||
description: "Tekstovne predloge za SMS obvestila in opomnike",
|
||||
route: "admin.sms-templates.index",
|
||||
icon: faFileLines,
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
{
|
||||
title: "SMS dnevniki",
|
||||
description: "Pregled poslanih SMSov in statusov",
|
||||
route: "admin.sms-logs.index",
|
||||
icon: faInbox,
|
||||
icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
title: "SMS paketi",
|
||||
description: "Kreiranje in pošiljanje serijskih SMS paketov",
|
||||
route: "admin.packages.index",
|
||||
icon: faInbox,
|
||||
icon: MessageSquareIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -112,44 +120,50 @@ const cards = [
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Administrator">
|
||||
<div class="space-y-14">
|
||||
<section
|
||||
v-for="(group, i) in cards"
|
||||
:key="group.category"
|
||||
:class="[i > 0 ? 'pt-6 border-t border-gray-200/70' : '']"
|
||||
>
|
||||
<h2 class="text-xs font-semibold tracking-wider uppercase text-gray-500 mb-4">
|
||||
{{ group.category }}
|
||||
</h2>
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="space-y-8">
|
||||
<div v-for="(group, i) in cards" :key="group.category">
|
||||
<Separator v-if="i > 0" class="my-8" />
|
||||
<div class="mb-6">
|
||||
<h2
|
||||
class="text-xs font-semibold tracking-wider uppercase text-muted-foreground"
|
||||
>
|
||||
{{ group.category }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link
|
||||
v-for="item in group.items"
|
||||
:key="item.title"
|
||||
:href="route(item.route)"
|
||||
class="group relative overflow-hidden p-5 rounded-lg border bg-white hover:border-indigo-300 hover:shadow transition focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="group"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-10 h-10 rounded-md bg-indigo-50 text-indigo-600 group-hover:bg-indigo-100"
|
||||
>
|
||||
<FontAwesomeIcon :icon="item.icon" class="w-5 h-5" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-sm mb-1 flex items-center gap-2">
|
||||
{{ item.title }}
|
||||
<span
|
||||
class="opacity-0 group-hover:opacity-100 transition text-indigo-500 text-[10px] font-medium"
|
||||
>→</span
|
||||
<Card class="h-full transition-all hover:border-primary hover:shadow-md">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary group-hover:bg-primary/20 transition-colors shrink-0"
|
||||
>
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 leading-relaxed line-clamp-3">
|
||||
<component :is="item.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<CardTitle class="text-base flex items-center gap-2 group">
|
||||
{{ item.title }}
|
||||
<ArrowRightIcon
|
||||
class="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition-opacity text-primary"
|
||||
/>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription class="text-sm leading-relaxed line-clamp-2">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user