Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f8e0c42ec | |||
| 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 |
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npx playwright test
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
+18
@@ -25,7 +25,25 @@ yarn-error.log
|
|||||||
check-*.php
|
check-*.php
|
||||||
test-*.php
|
test-*.php
|
||||||
fix-*.php
|
fix-*.php
|
||||||
|
clean-*.php
|
||||||
|
mark-*.php
|
||||||
|
|
||||||
# Development Documentation
|
# Development Documentation
|
||||||
IMPORT_*.md
|
IMPORT_*.md
|
||||||
V2_*.md
|
V2_*.md
|
||||||
|
REPORTS_*.md
|
||||||
|
DEDUPLICATION_*.md
|
||||||
|
|
||||||
|
# Docker Local Testing
|
||||||
|
docker-compose.local.yaml
|
||||||
|
docker-compose.override.yaml
|
||||||
|
.env.local
|
||||||
|
.env.docker
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|||||||
+1045
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 = [
|
public const COLUMN_METADATA = [
|
||||||
'reference' => ['label' => 'Pogodba'],
|
'reference' => ['label' => 'Pogodba'],
|
||||||
'client_case' => ['label' => 'Primer'],
|
'client_case' => ['label' => 'Primer'],
|
||||||
|
'address' => ['label' => 'Naslov'],
|
||||||
'client' => ['label' => 'Stranka'],
|
'client' => ['label' => 'Stranka'],
|
||||||
'type' => ['label' => 'Vrsta'],
|
'type' => ['label' => 'Vrsta'],
|
||||||
'start_date' => ['label' => 'Začetek'],
|
'start_date' => ['label' => 'Začetek'],
|
||||||
@@ -107,6 +108,7 @@ private function resolveValue(Contract $contract, string $column): mixed
|
|||||||
return match ($column) {
|
return match ($column) {
|
||||||
'reference' => $contract->reference,
|
'reference' => $contract->reference,
|
||||||
'client_case' => optional($contract->clientCase?->person)->full_name,
|
'client_case' => optional($contract->clientCase?->person)->full_name,
|
||||||
|
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||||
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
||||||
'type' => optional($contract->type)->name,
|
'type' => optional($contract->type)->name,
|
||||||
'start_date' => $this->formatDate($contract->start_date),
|
'start_date' => $this->formatDate($contract->start_date),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
use App\Models\SmsTemplate;
|
use App\Models\SmsTemplate;
|
||||||
use App\Services\Contact\PhoneSelector;
|
use App\Services\Contact\PhoneSelector;
|
||||||
use App\Services\Sms\SmsService;
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
@@ -23,9 +24,19 @@ class PackageController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$perPage = $request->input('per_page') ?? 25;
|
||||||
|
|
||||||
$packages = Package::query()
|
$packages = Package::query()
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->paginate(25);
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Packages/Index', [
|
||||||
|
'packages' => $packages,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
// Minimal lookups for create form (active only)
|
// Minimal lookups for create form (active only)
|
||||||
$profiles = \App\Models\SmsProfile::query()
|
$profiles = \App\Models\SmsProfile::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
@@ -40,6 +51,7 @@ public function index(Request $request): Response
|
|||||||
->get(['id', 'name', 'content']);
|
->get(['id', 'name', 'content']);
|
||||||
$segments = \App\Models\Segment::query()
|
$segments = \App\Models\Segment::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
|
->where('exclude', false)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name']);
|
->get(['id', 'name']);
|
||||||
// Provide a lightweight list of recent clients with person names for filtering
|
// Provide a lightweight list of recent clients with person names for filtering
|
||||||
@@ -58,8 +70,7 @@ public function index(Request $request): Response
|
|||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Admin/Packages/Index', [
|
return Inertia::render('Admin/Packages/Create', [
|
||||||
'packages' => $packages,
|
|
||||||
'profiles' => $profiles,
|
'profiles' => $profiles,
|
||||||
'senders' => $senders,
|
'senders' => $senders,
|
||||||
'templates' => $templates,
|
'templates' => $templates,
|
||||||
@@ -312,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
'q' => ['nullable', 'string'],
|
'q' => ['nullable', 'string'],
|
||||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
||||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||||
'only_mobile' => ['nullable', 'boolean'],
|
'only_mobile' => ['nullable', 'boolean'],
|
||||||
'only_validated' => ['nullable', 'boolean'],
|
'only_validated' => ['nullable', 'boolean'],
|
||||||
@@ -323,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
|
||||||
|
|
||||||
$query = Contract::query()
|
$query = Contract::query()
|
||||||
->with([
|
->with([
|
||||||
'clientCase.person.phones',
|
'clientCase.person.phones',
|
||||||
'clientCase.client.person',
|
'clientCase.client.person',
|
||||||
'account',
|
'account',
|
||||||
|
'segments:id,name',
|
||||||
])
|
])
|
||||||
->select('contracts.*')
|
->select('contracts.*')
|
||||||
->latest('contracts.id');
|
->latest('contracts.id');
|
||||||
@@ -341,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
->where('contract_segment.segment_id', '=', $segmentId)
|
->where('contract_segment.segment_id', '=', $segmentId)
|
||||||
->where('contract_segment.active', true);
|
->where('contract_segment.active', true);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Only include contracts that have at least one active, non-excluded segment
|
||||||
|
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||||
|
->from('contract_segment')
|
||||||
|
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||||
|
->where('contract_segment.active', true)
|
||||||
|
->where('segments.exclude', false)
|
||||||
|
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($q = trim((string) $request->input('q'))) {
|
if ($q = trim((string) $request->input('q'))) {
|
||||||
@@ -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;
|
$person = $contract->clientCase?->person;
|
||||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||||
$phone = $selected['phone'];
|
$phone = $selected['phone'];
|
||||||
$clientPerson = $contract->clientCase?->client?->person;
|
$clientPerson = $contract->clientCase?->client?->person;
|
||||||
|
$segment = collect($contract->segments)->last();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $contract->id,
|
'id' => $contract->id,
|
||||||
@@ -414,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
'uuid' => $person?->uuid,
|
'uuid' => $person?->uuid,
|
||||||
'full_name' => $person?->full_name,
|
'full_name' => $person?->full_name,
|
||||||
],
|
],
|
||||||
|
'segment' => $segment,
|
||||||
// Stranka: the client person
|
// Stranka: the client person
|
||||||
'client' => $clientPerson ? [
|
'client' => $clientPerson ? [
|
||||||
'id' => $contract->clientCase?->client?->id,
|
'id' => $contract->clientCase?->client?->id,
|
||||||
@@ -432,12 +453,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'meta' => [
|
|
||||||
'current_page' => $contracts->currentPage(),
|
|
||||||
'last_page' => $contracts->lastPage(),
|
|
||||||
'per_page' => $contracts->perPage(),
|
|
||||||
'total' => $contracts->total(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
'action_id' => 'exists:\App\Models\Action,id',
|
'action_id' => 'exists:\App\Models\Action,id',
|
||||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||||
'contract_uuid' => 'nullable|uuid',
|
'contract_uuid' => 'nullable|uuid',
|
||||||
|
'contract_uuids' => 'nullable|array',
|
||||||
|
'contract_uuids.*' => 'uuid',
|
||||||
|
'create_for_all_contracts' => 'nullable|boolean',
|
||||||
'phone_view' => 'nullable|boolean',
|
'phone_view' => 'nullable|boolean',
|
||||||
'send_auto_mail' => 'sometimes|boolean',
|
'send_auto_mail' => 'sometimes|boolean',
|
||||||
'attachment_document_ids' => 'sometimes|array',
|
'attachment_document_ids' => 'sometimes|array',
|
||||||
@@ -318,20 +321,53 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$isPhoneView = $attributes['phone_view'] ?? false;
|
$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
|
// Determine which contracts to process
|
||||||
$contractId = null;
|
$contractIds = [];
|
||||||
if (! empty($attributes['contract_uuid'])) {
|
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()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $attributes['contract_uuid'])
|
->where('uuid', $attributes['contract_uuid'])
|
||||||
->where('client_case_id', $clientCase->id)
|
->where('client_case_id', $clientCase->id)
|
||||||
->first();
|
->first();
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
// Archived contracts are allowed: link activity regardless of active flag
|
$contractIds = [$contract->id];
|
||||||
$contractId = $contract->id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no contracts specified, create a single activity without contract
|
||||||
|
if (empty($contractIds)) {
|
||||||
|
$contractIds = [null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($contractIds as $contractId) {
|
||||||
// Create activity
|
// Create activity
|
||||||
$row = $clientCase->activities()->create([
|
$row = $clientCase->activities()->create([
|
||||||
'due_date' => $attributes['due_date'] ?? null,
|
'due_date' => $attributes['due_date'] ?? null,
|
||||||
@@ -342,7 +378,11 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
'contract_id' => $contractId,
|
'contract_id' => $contractId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$createdActivities[] = $row;
|
||||||
|
|
||||||
if ($isPhoneView && $contractId) {
|
if ($isPhoneView && $contractId) {
|
||||||
|
$contract = Contract::find($contractId);
|
||||||
|
if ($contract) {
|
||||||
$fieldJob = $contract->fieldJobs()
|
$fieldJob = $contract->fieldJobs()
|
||||||
->whereNull('completed_at')
|
->whereNull('completed_at')
|
||||||
->whereNull('cancelled_at')
|
->whereNull('cancelled_at')
|
||||||
@@ -355,15 +395,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
'added_activity' => true,
|
'added_activity' => true,
|
||||||
'last_activity' => $row->created_at,
|
'last_activity' => $row->created_at,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger()->info('Activity successfully inserted', $attributes);
|
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
|
||||||
|
|
||||||
// Auto mail dispatch (best-effort)
|
// Auto mail dispatch (best-effort)
|
||||||
try {
|
try {
|
||||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
|
||||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||||
// Filter attachments to those belonging to the selected contract
|
// Filter attachments to those belonging to the selected contract
|
||||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||||
@@ -377,25 +416,36 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
->where('documentable_id', $contractId)
|
->where('documentable_id', $contractId)
|
||||||
->whereIn('id', $attachmentIds)
|
->whereIn('id', $attachmentIds)
|
||||||
->pluck('id');
|
->pluck('id');
|
||||||
|
$validAttachmentIds = Document::query()
|
||||||
|
->where('documentable_type', Contract::class)
|
||||||
|
->where('documentable_id', $contractId)
|
||||||
|
->whereIn('id', $attachmentIds)
|
||||||
|
->pluck('id');
|
||||||
}
|
}
|
||||||
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
|
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
|
||||||
'attachment_ids' => $validAttachmentIds->all(),
|
'attachment_ids' => $validAttachmentIds->all(),
|
||||||
]);
|
]);
|
||||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||||
// If template requires contract and user attempted to send, surface a validation message
|
// 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) {
|
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) {
|
} catch (\Throwable $e) {
|
||||||
// Do not fail activity creation due to mailing issues
|
// Do not fail activity creation due to mailing issues
|
||||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
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.
|
// 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.
|
// 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) {
|
} catch (QueryException $e) {
|
||||||
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
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.
|
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exports\ClientContractsExport;
|
||||||
|
use App\Http\Requests\ExportClientContractsRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Services\ReferenceDataCache;
|
use App\Services\ReferenceDataCache;
|
||||||
use DB;
|
use DB;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
class ClientController extends Controller
|
class ClientController extends Controller
|
||||||
{
|
{
|
||||||
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
|
|||||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||||
->groupBy('clients.id');
|
->groupBy('clients.id');
|
||||||
})
|
})
|
||||||
->where('clients.active', 1)
|
//->where('clients.active', 1)
|
||||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||||
->leftJoin('contracts', function ($join) {
|
->leftJoin('contracts', function ($join) {
|
||||||
@@ -47,7 +51,7 @@ public function index(Client $client, Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Client/Index', [
|
return Inertia::render('Client/Index', [
|
||||||
'clients' => $query
|
'clients' => $query
|
||||||
->paginate($request->integer('per_page', 15))
|
->paginate($request->integer('per_page', default: 100))
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
]);
|
]);
|
||||||
@@ -137,6 +141,7 @@ public function contracts(Client $client, Request $request)
|
|||||||
->with([
|
->with([
|
||||||
'clientCase:id,uuid,person_id',
|
'clientCase:id,uuid,person_id',
|
||||||
'clientCase.person:id,full_name',
|
'clientCase.person:id,full_name',
|
||||||
|
'clientCase.person.address',
|
||||||
'segments' => function ($q) {
|
'segments' => function ($q) {
|
||||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
$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)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ public function index(Request $request)
|
|||||||
$unassignedClients = $unassignedContracts->get()
|
$unassignedClients = $unassignedContracts->get()
|
||||||
->pluck('clientCase.client')
|
->pluck('clientCase.client')
|
||||||
->filter()
|
->filter()
|
||||||
->unique('id');
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
|
||||||
$assignedContracts = Contract::query()
|
$assignedContracts = Contract::query()
|
||||||
@@ -98,7 +99,8 @@ public function index(Request $request)
|
|||||||
$assignedClients = $assignedContracts->get()
|
$assignedClients = $assignedContracts->get()
|
||||||
->pluck('clientCase.client')
|
->pluck('clientCase.client')
|
||||||
->filter()
|
->filter()
|
||||||
->unique('id');
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
|
||||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
use App\Models\ImportEvent;
|
use App\Models\ImportEvent;
|
||||||
use App\Models\ImportTemplate;
|
use App\Models\ImportTemplate;
|
||||||
use App\Services\CsvImportService;
|
use App\Services\CsvImportService;
|
||||||
use App\Services\Import\ImportServiceV2;
|
|
||||||
use App\Services\Import\ImportSimulationServiceV2;
|
use App\Services\Import\ImportSimulationServiceV2;
|
||||||
use App\Services\ImportProcessor;
|
use App\Services\ImportProcessor;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -184,12 +183,13 @@ public function store(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kick off processing of an import - simple synchronous step for now
|
// Kick off processing of an import - simple synchronous step for now
|
||||||
public function process(Import $import, Request $request, ImportServiceV2 $processor)
|
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||||
{
|
{
|
||||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $processor->process($import, user: $request->user());
|
$result = $processor->process($import, user: $request->user());
|
||||||
|
|
||||||
return response()->json($result);
|
return response()->json($result);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Log::error('Import processing failed', [
|
\Log::error('Import processing failed', [
|
||||||
@@ -712,8 +712,6 @@ public function simulatePayments(Import $import, Request $request)
|
|||||||
* templates. For payments templates, payment-specific summaries/entities will be included
|
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||||
* automatically by the simulation service when mappings contain the payment root.
|
* automatically by the simulation service when mappings contain the payment root.
|
||||||
*
|
*
|
||||||
* @param Import $import
|
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
* @return \Illuminate\Http\JsonResponse
|
||||||
*/
|
*/
|
||||||
public function simulate(Import $import, Request $request)
|
public function simulate(Import $import, Request $request)
|
||||||
@@ -829,4 +827,19 @@ public function destroy(Request $request, Import $import)
|
|||||||
|
|
||||||
return back()->with('success', 'Import deleted successfully');
|
return back()->with('success', 'Import deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download the original import file
|
||||||
|
public function download(Import $import)
|
||||||
|
{
|
||||||
|
// Verify file exists
|
||||||
|
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'File not found',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = $import->original_name ?? 'import_'.$import->uuid;
|
||||||
|
|
||||||
|
return Storage::disk($import->disk)->download($import->path, $fileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public function unread(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$today = now()->toDateString();
|
$today = now()->toDateString();
|
||||||
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
|
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
|
||||||
$search = trim((string) $request->input('search', ''));
|
$search = trim((string) $request->input('search', ''));
|
||||||
$clientUuid = trim((string) $request->input('client', ''));
|
$clientUuid = trim((string) $request->input('client', ''));
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ public function __construct(protected ReferenceDataCache $referenceCache) {}
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$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)
|
->where('assigned_user_id', $userId)
|
||||||
->whereNull('completed_at')
|
->whereNull('completed_at')
|
||||||
->whereNull('cancelled_at')
|
->whereNull('cancelled_at')
|
||||||
@@ -23,32 +27,78 @@ public function index(Request $request)
|
|||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
'account',
|
'account',
|
||||||
'clientCase.person' => function ($pq) {
|
'clientCase.person.address.type',
|
||||||
$pq->with(['addresses', 'phones']);
|
'clientCase.person.phones',
|
||||||
},
|
|
||||||
'clientCase.client:id,uuid,person_id',
|
'clientCase.client:id,uuid,person_id',
|
||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->orderByDesc('assigned_at')
|
->orderByDesc('assigned_at');
|
||||||
->limit(100)
|
|
||||||
->get();
|
// 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', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'jobs' => $jobs,
|
||||||
|
'clients' => $clients,
|
||||||
'view_mode' => 'assigned',
|
'view_mode' => 'assigned',
|
||||||
|
'filters' => [
|
||||||
|
'search' => $search,
|
||||||
|
'client' => $clientFilter,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completedToday(Request $request)
|
public function completedToday(Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$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();
|
$start = now()->startOfDay();
|
||||||
$end = now()->endOfDay();
|
$end = now()->endOfDay();
|
||||||
|
|
||||||
$jobs = FieldJob::query()
|
$query = FieldJob::query()
|
||||||
->where('assigned_user_id', $userId)
|
->where('assigned_user_id', $userId)
|
||||||
->whereNull('cancelled_at')
|
->whereNull('cancelled_at')
|
||||||
->whereBetween('completed_at', [$start, $end])
|
->whereBetween('completed_at', [$start, $end])
|
||||||
@@ -57,21 +107,63 @@ public function completedToday(Request $request)
|
|||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
'account',
|
'account',
|
||||||
'clientCase.person' => function ($pq) {
|
'clientCase.person.address.type',
|
||||||
$pq->with(['addresses', 'phones']);
|
'clientCase.person.phones',
|
||||||
},
|
|
||||||
'clientCase.client:id,uuid,person_id',
|
'clientCase.client:id,uuid,person_id',
|
||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->orderByDesc('completed_at')
|
->orderByDesc('completed_at');
|
||||||
->limit(100)
|
|
||||||
->get();
|
// 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', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'jobs' => $jobs,
|
||||||
|
'clients' => $clients,
|
||||||
'view_mode' => 'completed-today',
|
'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');
|
$completedMode = $request->boolean('completed');
|
||||||
|
|
||||||
// Eager load case with person details
|
// 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
|
// Query contracts based on field jobs
|
||||||
$contractsQuery = FieldJob::query()
|
$contractsQuery = FieldJob::query()
|
||||||
@@ -131,7 +223,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
|||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
return Inertia::render('Phone/Case/Index', [
|
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,
|
'client_case' => $case,
|
||||||
'contracts' => $contracts,
|
'contracts' => $contracts,
|
||||||
'documents' => $documents,
|
'documents' => $documents,
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ public function show(Segment $segment)
|
|||||||
|
|
||||||
$contracts = $this->hydrateClientShortcut($contracts);
|
$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()
|
$clients = Client::query()
|
||||||
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
||||||
$q->where('segments.id', $segment->id)
|
$q->where('segments.id', $segment->id)
|
||||||
@@ -191,8 +197,7 @@ private function buildContractsQuery(Segment $segment, ?string $search, ?string
|
|||||||
->where('contract_segment.active', '=', 1);
|
->where('contract_segment.active', '=', 1);
|
||||||
})
|
})
|
||||||
->with([
|
->with([
|
||||||
'clientCase.person',
|
'clientCase.person.address',
|
||||||
'clientCase.client.person',
|
|
||||||
'type',
|
'type',
|
||||||
'account',
|
'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) {
|
if ($template && $case) {
|
||||||
$note = '';
|
$note = '';
|
||||||
if ($log->status === 'sent') {
|
if ($log->status === 'sent') {
|
||||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||||
} elseif ($log->status === 'failed') {
|
} elseif ($log->status === 'failed') {
|
||||||
$note = sprintf(
|
$note = sprintf(
|
||||||
'Št: %s | Telo: %s | Napaka: %s',
|
'Tel: %s | Telo: %s | Napaka: %s',
|
||||||
(string) $this->to,
|
(string) $this->to,
|
||||||
(string) $this->content,
|
(string) $this->content,
|
||||||
'SMS ni bil poslan!'
|
'SMS ni bil poslan!'
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Account extends Model
|
class Account extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||||
|
use SoftDeletes;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Person extends Model
|
|||||||
'group_id',
|
'group_id',
|
||||||
'type_id',
|
'type_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'employer'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -112,6 +113,14 @@ public function addresses(): HasMany
|
|||||||
->orderBy('id');
|
->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
|
public function emails(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Email::class, 'person_id')
|
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 = $this->buildPayloadForAddress($address);
|
||||||
$payload['person_id'] = $personId;
|
$payload['person_id'] = $personId;
|
||||||
|
|
||||||
$addressEntity = new \App\Models\Person\PersonAddress;
|
$addressEntity = new PersonAddress;
|
||||||
$addressEntity->fill($payload);
|
$addressEntity->fill($payload);
|
||||||
$addressEntity->save();
|
$addressEntity->save();
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
|||||||
|
|
||||||
protected function resolveAddress(string $address, int $personId): mixed
|
protected function resolveAddress(string $address, int $personId): mixed
|
||||||
{
|
{
|
||||||
return \App\Models\Person\PersonAddress::where('person_id', $personId)
|
return PersonAddress::where('person_id', $personId)
|
||||||
->where('address', $address)
|
->where('address', $address)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
use App\Models\Person\PersonPhone;
|
use App\Models\Person\PersonPhone;
|
||||||
use App\Models\Person\PersonType;
|
use App\Models\Person\PersonType;
|
||||||
use App\Models\Person\PhoneType;
|
use App\Models\Person\PhoneType;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
|
|
||||||
$existing = Account::query()
|
$existing = Account::query()
|
||||||
->where('contract_id', $contractId)
|
->where('contract_id', $contractId)
|
||||||
->where('reference', $reference)
|
//->where('reference', $reference)
|
||||||
->where('active', 1)
|
->where('active', 1)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -1655,6 +1656,14 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
$value = $acc[$field] ?? null;
|
$value = $acc[$field] ?? null;
|
||||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||||
$value = $this->normalizeDecimal($value);
|
$value = $this->normalizeDecimal($value);
|
||||||
|
// Ensure the normalized value is numeric, otherwise default to 0
|
||||||
|
if ($value === '' || $value === '-' || ! is_numeric($value)) {
|
||||||
|
$value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Convert empty string to 0 for amount fields
|
||||||
|
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
|
||||||
|
$value = 0;
|
||||||
}
|
}
|
||||||
$mode = $map->apply_mode ?? 'both';
|
$mode = $map->apply_mode ?? 'both';
|
||||||
if ($mode === 'keyref') {
|
if ($mode === 'keyref') {
|
||||||
@@ -1684,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Build non-null changes for account fields
|
// Build non-null changes for account fields
|
||||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||||
// Track balance change
|
// Track balance change - normalize in case DB has malformed data
|
||||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
$rawBalance = $existing->balance_amount ?? 0;
|
||||||
|
if (is_string($rawBalance) && $rawBalance !== '') {
|
||||||
|
$rawBalance = $this->normalizeDecimal($rawBalance);
|
||||||
|
}
|
||||||
|
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
|
||||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||||
if (! empty($changes)) {
|
if (! empty($changes)) {
|
||||||
$existing->fill($changes);
|
$existing->fill($changes);
|
||||||
@@ -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 balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||||
if (array_key_exists('balance_amount', $changes)) {
|
if (array_key_exists('balance_amount', $changes)) {
|
||||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
$rawNewBalance = $existing->balance_amount ?? 0;
|
||||||
|
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
|
||||||
|
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
|
||||||
|
}
|
||||||
|
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
|
||||||
if ($newBalance !== $oldBalance) {
|
if ($newBalance !== $oldBalance) {
|
||||||
try {
|
try {
|
||||||
$contractId = $existing->contract_id;
|
$contractId = $existing->contract_id;
|
||||||
@@ -2970,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
|
|||||||
// Create person if any fields present; ensure required foreign keys
|
// Create person if any fields present; ensure required foreign keys
|
||||||
if (! empty($p)) {
|
if (! empty($p)) {
|
||||||
$data = [];
|
$data = [];
|
||||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
|
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id', 'employer'] as $k) {
|
||||||
if (array_key_exists($k, $p)) {
|
if (array_key_exists($k, $p)) {
|
||||||
$data[$k] = $p[$k];
|
$data[$k] = $p[$k];
|
||||||
}
|
}
|
||||||
@@ -2983,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
|
|||||||
$data['full_name'] = trim($fn.' '.$ln);
|
$data['full_name'] = trim($fn.' '.$ln);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalise birthday date
|
||||||
|
if (!empty($data['birthday'])) {
|
||||||
|
try {
|
||||||
|
$data['birthday'] = date('Y-m-d', strtotime($data['birthday']));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::warning('ImportProcessor::findOrCreatePersonId ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
// ensure required group/type ids
|
// ensure required group/type ids
|
||||||
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
||||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
||||||
@@ -3159,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
|||||||
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||||
$addrData['country'] = 'SLO';
|
$addrData['country'] = 'SLO';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($addrData['city']) && empty($addrData['post_code'])) {
|
||||||
|
if (preg_match('/^\d{3,}\s+/',trim($addrData['city']))) {
|
||||||
|
$cleanStrCity = str($addrData['city'])->squish()->value();
|
||||||
|
$splitCity = preg_split('/\s/', $cleanStrCity, 2);
|
||||||
|
if (count($splitCity) >= 2) {
|
||||||
|
$addrData['post_code'] = $splitCity[0];
|
||||||
|
$addrData['city'] = $splitCity[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Compare addresses with all spaces removed to handle whitespace variations
|
// Compare addresses with all spaces removed to handle whitespace variations
|
||||||
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
/*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||||
|
|
||||||
|
|
||||||
$existing = PersonAddress::where('person_id', $personId)
|
$existing = PersonAddress::where('person_id', $personId)
|
||||||
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
|
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
|
||||||
|
->first();*/
|
||||||
|
|
||||||
|
// Build search query combining address, post_code and city
|
||||||
|
$searchParts = [$addrData['address']];
|
||||||
|
if (!empty($addrData['post_code'])) {
|
||||||
|
$searchParts[] = $addrData['post_code'];
|
||||||
|
}
|
||||||
|
if (!empty($addrData['city'])) {
|
||||||
|
$searchParts[] = $addrData['city'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchQuery = implode(' ', $searchParts);
|
||||||
|
// Use fulltext search (GIN index optimized)
|
||||||
|
$existing = PersonAddress::query()->where('person_id', $personId)
|
||||||
|
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$applyInsert = [];
|
$applyInsert = [];
|
||||||
@@ -3207,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
|||||||
$data['person_id'] = $personId;
|
$data['person_id'] = $personId;
|
||||||
$data['country'] = $data['country'] ?? 'SLO';
|
$data['country'] = $data['country'] ?? 'SLO';
|
||||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
||||||
|
|
||||||
|
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
|
||||||
|
$data['post_code'] = $addrData['post_code'];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$created = PersonAddress::create($data);
|
$created = PersonAddress::create($data);
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve --no-reload --port=8090\" \"php artisan queue:listen --tries=1\" \"npm run dev\" --names='server,queue,vite'"
|
||||||
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
'features' => [
|
'features' => [
|
||||||
// Features::termsAndPrivacyPolicy(),
|
// Features::termsAndPrivacyPolicy(),
|
||||||
// Features::profilePhotos(),
|
// Features::profilePhotos(),
|
||||||
Features::api(),
|
// Features::api(),
|
||||||
// Features::teams(['invitations' => true]),
|
// Features::teams(['invitations' => true]),
|
||||||
Features::accountDeletion(),
|
Features::accountDeletion(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('person', function (Blueprint $table){
|
||||||
|
$table->string('employer', 125)->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person', function (Blueprint $table){
|
||||||
|
$table->dropColumn('employer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Add a generated tsvector column for fulltext search
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE person_addresses
|
||||||
|
ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(address, '') || ' ' ||
|
||||||
|
coalesce(post_code, '') || ' ' ||
|
||||||
|
coalesce(city, '')
|
||||||
|
)
|
||||||
|
) STORED
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create GIN index on the tsvector column for fast fulltext search
|
||||||
|
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('person_addresses_search_vector_idx');
|
||||||
|
$table->dropColumn('search_vector');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('person_addresses_search_vector_idx');
|
||||||
|
$table->dropColumn('search_vector');
|
||||||
|
|
||||||
|
|
||||||
|
$table->string('post_code', 50)->nullable()->change();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a generated tsvector column for fulltext search
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE person_addresses
|
||||||
|
ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(address, '') || ' ' ||
|
||||||
|
coalesce(post_code, '') || ' ' ||
|
||||||
|
coalesce(city, '')
|
||||||
|
)
|
||||||
|
) STORED
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create GIN index on the tsvector column for fast fulltext search
|
||||||
|
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->string('post_code', 20)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,7 +14,7 @@ public function run(): void
|
|||||||
'key' => 'person',
|
'key' => 'person',
|
||||||
'canonical_root' => 'person',
|
'canonical_root' => 'person',
|
||||||
'label' => 'Person',
|
'label' => 'Person',
|
||||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
|
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
|
||||||
'field_aliases' => [
|
'field_aliases' => [
|
||||||
'dob' => 'birthday',
|
'dob' => 'birthday',
|
||||||
'date_of_birth' => 'birthday',
|
'date_of_birth' => 'birthday',
|
||||||
@@ -30,6 +30,7 @@ public function run(): void
|
|||||||
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
||||||
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
||||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||||
|
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
|
||||||
],
|
],
|
||||||
'ui' => ['order' => 1],
|
'ui' => ['order' => 1],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('has title', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/Playwright/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get started link', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Click the get started link.
|
||||||
|
await page.getByRole('link', { name: 'Get started' }).click();
|
||||||
|
|
||||||
|
// Expects page to have a heading with the name of Installation.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||||
|
});
|
||||||
Generated
+64
-18
@@ -48,6 +48,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "2.0",
|
"@inertiajs/vue3": "2.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -1126,6 +1127,22 @@
|
|||||||
"state-local": "^1.0.6"
|
"state-local": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -5021,6 +5038,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -6029,24 +6093,6 @@
|
|||||||
"which": "bin/which"
|
"which": "bin/which"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "2.0",
|
"@inertiajs/vue3": "2.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://localhost:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, nextTick } from 'vue';
|
import { ref, reactive, nextTick } from "vue";
|
||||||
import DialogModal from './DialogModal.vue';
|
import DialogModal from "./DialogModal.vue";
|
||||||
import InputError from './InputError.vue';
|
import InputError from "./InputError.vue";
|
||||||
import PrimaryButton from './PrimaryButton.vue';
|
import PrimaryButton from "./PrimaryButton.vue";
|
||||||
import SecondaryButton from './SecondaryButton.vue';
|
import SecondaryButton from "./SecondaryButton.vue";
|
||||||
import TextInput from './TextInput.vue';
|
import { Input } from "@/Components/ui/input";
|
||||||
|
|
||||||
const emit = defineEmits(['confirmed']);
|
const emit = defineEmits(["confirmed"]);
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Confirm Password',
|
default: "Confirm Password",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'For your security, please confirm your password to continue.',
|
default: "For your security, please confirm your password to continue.",
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Confirm',
|
default: "Confirm",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmingPassword = ref(false);
|
const confirmingPassword = ref(false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
password: '',
|
password: "",
|
||||||
error: '',
|
error: "",
|
||||||
processing: false,
|
processing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const passwordInput = ref(null);
|
const passwordInput = ref(null);
|
||||||
|
|
||||||
const startConfirmingPassword = () => {
|
const startConfirmingPassword = () => {
|
||||||
axios.get(route('password.confirmation')).then(response => {
|
axios.get(route("password.confirmation")).then((response) => {
|
||||||
if (response.data.confirmed) {
|
if (response.data.confirmed) {
|
||||||
emit('confirmed');
|
emit("confirmed");
|
||||||
} else {
|
} else {
|
||||||
confirmingPassword.value = true;
|
confirmingPassword.value = true;
|
||||||
|
|
||||||
@@ -48,15 +48,17 @@ const startConfirmingPassword = () => {
|
|||||||
const confirmPassword = () => {
|
const confirmPassword = () => {
|
||||||
form.processing = true;
|
form.processing = true;
|
||||||
|
|
||||||
axios.post(route('password.confirm'), {
|
axios
|
||||||
|
.post(route("password.confirm"), {
|
||||||
password: form.password,
|
password: form.password,
|
||||||
}).then(() => {
|
})
|
||||||
|
.then(() => {
|
||||||
form.processing = false;
|
form.processing = false;
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
nextTick().then(() => emit('confirmed'));
|
nextTick().then(() => emit("confirmed"));
|
||||||
|
})
|
||||||
}).catch(error => {
|
.catch((error) => {
|
||||||
form.processing = false;
|
form.processing = false;
|
||||||
form.error = error.response.data.errors.password[0];
|
form.error = error.response.data.errors.password[0];
|
||||||
passwordInput.value.focus();
|
passwordInput.value.focus();
|
||||||
@@ -65,8 +67,8 @@ const confirmPassword = () => {
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
confirmingPassword.value = false;
|
confirmingPassword.value = false;
|
||||||
form.password = '';
|
form.password = "";
|
||||||
form.error = '';
|
form.error = "";
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ const closeModal = () => {
|
|||||||
{{ content }}
|
{{ content }}
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<TextInput
|
<Input
|
||||||
ref="passwordInput"
|
ref="passwordInput"
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -100,9 +102,7 @@ const closeModal = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="closeModal">
|
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
|
||||||
Cancel
|
|
||||||
</SecondaryButton>
|
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
class="ms-3"
|
class="ms-3"
|
||||||
|
|||||||
@@ -462,6 +462,17 @@ function keyOf(row) {
|
|||||||
return row[props.rowKey];
|
return row[props.rowKey];
|
||||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<Dialog v-model:open="open">
|
||||||
<DialogContent :class="maxWidthClass">
|
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -6,34 +6,40 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/Components/ui/dialog';
|
} from "@/Components/ui/dialog";
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from "@/Components/ui/button";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: { type: Boolean, default: false },
|
show: { type: Boolean, default: false },
|
||||||
title: { type: String, default: 'Izbriši' },
|
title: { type: String, default: "Izbriši" },
|
||||||
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
|
message: {
|
||||||
confirmText: { type: String, default: 'Izbriši' },
|
type: String,
|
||||||
cancelText: { type: String, default: 'Prekliči' },
|
default: "Ali ste prepričani, da želite izbrisati ta element?",
|
||||||
|
},
|
||||||
|
confirmText: { type: String, default: "Izbriši" },
|
||||||
|
cancelText: { type: String, default: "Prekliči" },
|
||||||
processing: { type: Boolean, default: false },
|
processing: { type: Boolean, default: false },
|
||||||
itemName: { type: String, default: null }, // Optional name to show in confirmation
|
itemName: { type: String, default: null }, // Optional name to show in confirmation
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
const emit = defineEmits(["update:show", "close", "confirm"]);
|
||||||
|
|
||||||
const open = ref(props.show);
|
const open = ref(props.show);
|
||||||
|
|
||||||
watch(() => props.show, (newVal) => {
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
open.value = newVal;
|
open.value = newVal;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
watch(open, (newVal) => {
|
watch(open, (newVal) => {
|
||||||
emit('update:show', newVal);
|
emit("update:show", newVal);
|
||||||
if (!newVal) {
|
if (!newVal) {
|
||||||
emit('close');
|
emit("close");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@ const onClose = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
emit('confirm');
|
emit("confirm");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,8 +65,13 @@ const onConfirm = () => {
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<div class="flex items-start gap-4 pt-4">
|
<div class="flex items-start gap-4 pt-4">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
<div
|
||||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" />
|
class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="faTriangleExclamation"
|
||||||
|
class="h-6 w-6 text-red-600"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
@@ -70,9 +81,7 @@ const onConfirm = () => {
|
|||||||
<p v-if="itemName" class="text-sm font-medium text-gray-900">
|
<p v-if="itemName" class="text-sm font-medium text-gray-900">
|
||||||
{{ itemName }}
|
{{ itemName }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
|
||||||
Ta dejanje ni mogoče razveljaviti.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -82,15 +91,10 @@ const onConfirm = () => {
|
|||||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||||
{{ cancelText }}
|
{{ cancelText }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" @click="onConfirm" :disabled="processing">
|
||||||
variant="destructive"
|
|
||||||
@click="onConfirm"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
{{ confirmText }}
|
{{ confirmText }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<Dialog v-model:open="open">
|
||||||
<DialogContent :class="maxWidthClass">
|
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
|
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from "vee-validate";
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as z from 'zod'
|
import * as z from "zod";
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from "vue";
|
||||||
import { router } from '@inertiajs/vue3'
|
import { router } from "@inertiajs/vue3";
|
||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form'
|
import {
|
||||||
import { Input } from '@/Components/ui/input'
|
FormControl,
|
||||||
import { Textarea } from '@/Components/ui/textarea'
|
FormField,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
|
FormItem,
|
||||||
import { Switch } from '@/Components/ui/switch'
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/Components/ui/form";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
import { Switch } from "@/Components/ui/switch";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: { type: Boolean, default: false },
|
show: { type: Boolean, default: false },
|
||||||
@@ -17,112 +29,128 @@ const props = defineProps({
|
|||||||
// Optional list of contracts to allow attaching the document directly to a contract
|
// Optional list of contracts to allow attaching the document directly to a contract
|
||||||
// Each item should have at least: { uuid, reference }
|
// Each item should have at least: { uuid, reference }
|
||||||
contracts: { type: Array, default: () => [] },
|
contracts: { type: Array, default: () => [] },
|
||||||
})
|
});
|
||||||
const emit = defineEmits(['close', 'uploaded'])
|
const emit = defineEmits(["close", "uploaded"]);
|
||||||
|
|
||||||
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
|
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
|
||||||
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
|
const ALLOWED_EXTS = [
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"pdf",
|
||||||
|
"txt",
|
||||||
|
"csv",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"jpeg",
|
||||||
|
"jpg",
|
||||||
|
"png",
|
||||||
|
];
|
||||||
|
|
||||||
const formSchema = toTypedSchema(z.object({
|
const formSchema = toTypedSchema(
|
||||||
name: z.string().min(1, 'Ime je obvezno'),
|
z.object({
|
||||||
|
name: z.string().min(1, "Ime je obvezno"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'),
|
file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
|
||||||
is_public: z.boolean().default(true),
|
is_public: z.boolean().default(true),
|
||||||
contract_uuid: z.string().nullable().optional(),
|
contract_uuid: z.string().nullable().optional(),
|
||||||
}))
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validationSchema: formSchema,
|
validationSchema: formSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: '',
|
name: "",
|
||||||
description: '',
|
description: "",
|
||||||
file: null,
|
file: null,
|
||||||
is_public: true,
|
is_public: true,
|
||||||
contract_uuid: null,
|
contract_uuid: null,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const localError = ref('')
|
const localError = ref("");
|
||||||
|
|
||||||
watch(() => props.show, (v) => {
|
watch(
|
||||||
if (!v) return
|
() => props.show,
|
||||||
localError.value = ''
|
(v) => {
|
||||||
form.resetForm()
|
if (!v) return;
|
||||||
})
|
localError.value = "";
|
||||||
|
form.resetForm();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const onFileChange = (e) => {
|
const onFileChange = (e) => {
|
||||||
localError.value = ''
|
localError.value = "";
|
||||||
const f = e.target.files?.[0]
|
const f = e.target.files?.[0];
|
||||||
if (!f) {
|
if (!f) {
|
||||||
form.setFieldValue('file', null)
|
form.setFieldValue("file", null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const ext = (f.name.split('.').pop() || '').toLowerCase()
|
const ext = (f.name.split(".").pop() || "").toLowerCase();
|
||||||
if (!ALLOWED_EXTS.includes(ext)) {
|
if (!ALLOWED_EXTS.includes(ext)) {
|
||||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||||
e.target.value = ''
|
e.target.value = "";
|
||||||
form.setFieldValue('file', null)
|
form.setFieldValue("file", null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (f.size > MAX_SIZE) {
|
if (f.size > MAX_SIZE) {
|
||||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||||
e.target.value = ''
|
e.target.value = "";
|
||||||
form.setFieldValue('file', null)
|
form.setFieldValue("file", null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
form.setFieldValue('file', f)
|
form.setFieldValue("file", f);
|
||||||
if (!form.values.name) {
|
if (!form.values.name) {
|
||||||
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
|
form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = form.handleSubmit(async (values) => {
|
const submit = form.handleSubmit(async (values) => {
|
||||||
localError.value = ''
|
localError.value = "";
|
||||||
if (!values.file) {
|
if (!values.file) {
|
||||||
localError.value = 'Prosimo izberite datoteko.'
|
localError.value = "Prosimo izberite datoteko.";
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
|
const ext = (values.file.name.split(".").pop() || "").toLowerCase();
|
||||||
if (!ALLOWED_EXTS.includes(ext)) {
|
if (!ALLOWED_EXTS.includes(ext)) {
|
||||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (values.file.size > MAX_SIZE) {
|
if (values.file.size > MAX_SIZE) {
|
||||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('name', values.name)
|
formData.append("name", values.name);
|
||||||
formData.append('description', values.description || '')
|
formData.append("description", values.description || "");
|
||||||
formData.append('file', values.file)
|
formData.append("file", values.file);
|
||||||
formData.append('is_public', values.is_public ? '1' : '0')
|
formData.append("is_public", values.is_public ? "1" : "0");
|
||||||
if (values.contract_uuid) {
|
if (values.contract_uuid) {
|
||||||
formData.append('contract_uuid', values.contract_uuid)
|
formData.append("contract_uuid", values.contract_uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(props.postUrl, formData, {
|
router.post(props.postUrl, formData, {
|
||||||
forceFormData: true,
|
forceFormData: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
emit('uploaded')
|
emit("uploaded");
|
||||||
emit('close')
|
emit("close");
|
||||||
form.resetForm()
|
form.resetForm();
|
||||||
},
|
},
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
// Set form errors if any
|
// Set form errors if any
|
||||||
if (errors.name) form.setFieldError('name', errors.name)
|
if (errors.name) form.setFieldError("name", errors.name);
|
||||||
if (errors.description) form.setFieldError('description', errors.description)
|
if (errors.description) form.setFieldError("description", errors.description);
|
||||||
if (errors.file) form.setFieldError('file', errors.file)
|
if (errors.file) form.setFieldError("file", errors.file);
|
||||||
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
|
if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const close = () => emit('close')
|
const close = () => emit("close");
|
||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
submit()
|
submit();
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -137,7 +165,11 @@ const onConfirm = () => {
|
|||||||
@confirm="onConfirm"
|
@confirm="onConfirm"
|
||||||
>
|
>
|
||||||
<form @submit.prevent="submit" class="space-y-4">
|
<form @submit.prevent="submit" class="space-y-4">
|
||||||
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid">
|
<FormField
|
||||||
|
v-if="props.contracts && props.contracts.length"
|
||||||
|
v-slot="{ value, handleChange }"
|
||||||
|
name="contract_uuid"
|
||||||
|
>
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Pripiši k</FormLabel>
|
<FormLabel>Pripiši k</FormLabel>
|
||||||
<Select :model-value="value" @update:model-value="handleChange">
|
<Select :model-value="value" @update:model-value="handleChange">
|
||||||
@@ -148,11 +180,7 @@ const onConfirm = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem :value="null">Primer</SelectItem>
|
<SelectItem :value="null">Primer</SelectItem>
|
||||||
<SelectItem
|
<SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
|
||||||
v-for="c in props.contracts"
|
|
||||||
:key="c.uuid"
|
|
||||||
:value="c.uuid"
|
|
||||||
>
|
|
||||||
Pogodba: {{ c.reference }}
|
Pogodba: {{ c.reference }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -165,7 +193,11 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Ime</FormLabel>
|
<FormLabel>Ime</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input id="doc_name" v-bind="componentField" />
|
<Input
|
||||||
|
id="doc_name"
|
||||||
|
v-bind="componentField"
|
||||||
|
class="w-full max-w-full overflow-hidden text-ellipsis"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -184,29 +216,24 @@ const onConfirm = () => {
|
|||||||
<FormField v-slot="{ value, handleChange }" name="file">
|
<FormField v-slot="{ value, handleChange }" name="file">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Datoteka (max 25MB)</FormLabel>
|
<FormLabel>Datoteka (max 25MB)</FormLabel>
|
||||||
<FormControl>
|
<FormControl class="flex w-full">
|
||||||
<Input
|
<Input
|
||||||
id="doc_file"
|
id="doc_file"
|
||||||
type="file"
|
type="file"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
|
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
|
||||||
|
class="min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
||||||
<div v-if="value" class="text-sm text-gray-600 mt-1">
|
|
||||||
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField v-slot="{ value, handleChange }" name="is_public">
|
<FormField v-slot="{ value, handleChange }" name="is_public">
|
||||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||||
:model-value="value"
|
|
||||||
@update:model-value="handleChange"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div class="space-y-1 leading-none">
|
<div class="space-y-1 leading-none">
|
||||||
<FormLabel>Javno</FormLabel>
|
<FormLabel>Javno</FormLabel>
|
||||||
|
|||||||
@@ -1,30 +1,219 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/Components/ui/dialog'
|
} from "@/Components/ui/dialog";
|
||||||
import { Button } from '@/Components/ui/button'
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Loader2 } from "lucide-vue-next";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: { type: Boolean, default: false },
|
show: { type: Boolean, default: false },
|
||||||
src: { type: String, default: '' },
|
src: { type: String, default: "" },
|
||||||
title: { type: String, default: 'Dokument' }
|
title: { type: String, default: "Dokument" },
|
||||||
})
|
mimeType: { type: String, default: "" },
|
||||||
const emit = defineEmits(['close'])
|
filename: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
const textContent = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const previewGenerating = ref(false);
|
||||||
|
const previewError = ref("");
|
||||||
|
|
||||||
|
const fileExtension = computed(() => {
|
||||||
|
if (props.filename) {
|
||||||
|
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewerType = computed(() => {
|
||||||
|
const ext = fileExtension.value;
|
||||||
|
const mime = props.mimeType.toLowerCase();
|
||||||
|
|
||||||
|
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||||
|
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
|
||||||
|
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||||||
|
return "docx";
|
||||||
|
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
|
||||||
|
return "image";
|
||||||
|
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||||||
|
|
||||||
|
return "unsupported";
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadTextContent = async () => {
|
||||||
|
if (!props.src || viewerType.value !== "text") return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get(props.src);
|
||||||
|
textContent.value = response.data;
|
||||||
|
} catch (e) {
|
||||||
|
textContent.value = "Napaka pri nalaganju vsebine.";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
|
||||||
|
// we poll until it's available.
|
||||||
|
const docxPreviewUrl = ref("");
|
||||||
|
const loadDocxPreview = async () => {
|
||||||
|
if (!props.src || viewerType.value !== "docx") return;
|
||||||
|
|
||||||
|
previewGenerating.value = true;
|
||||||
|
previewError.value = "";
|
||||||
|
docxPreviewUrl.value = "";
|
||||||
|
|
||||||
|
const maxRetries = 15;
|
||||||
|
const retryDelay = 2000; // 2 seconds between retries
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.head(props.src, { validateStatus: () => true });
|
||||||
|
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
// Preview is ready
|
||||||
|
docxPreviewUrl.value = props.src;
|
||||||
|
previewGenerating.value = false;
|
||||||
|
return;
|
||||||
|
} else if (response.status === 202) {
|
||||||
|
// Preview is being generated, wait and retry
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||||
|
} else {
|
||||||
|
// Other error
|
||||||
|
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||||
|
previewGenerating.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||||
|
previewGenerating.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max retries reached
|
||||||
|
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||||||
|
previewGenerating.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.src],
|
||||||
|
([show]) => {
|
||||||
|
if (show && viewerType.value === "text") {
|
||||||
|
loadTextContent();
|
||||||
|
}
|
||||||
|
if (show && viewerType.value === "docx") {
|
||||||
|
loadDocxPreview();
|
||||||
|
}
|
||||||
|
// Reset states when dialog closes
|
||||||
|
if (!show) {
|
||||||
|
previewGenerating.value = false;
|
||||||
|
previewError.value = "";
|
||||||
|
docxPreviewUrl.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
||||||
<DialogContent class="max-w-4xl">
|
<DialogContent class="max-w-full xl:max-w-7xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{{ title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Badge>
|
||||||
|
{{ fileExtension }}
|
||||||
|
</Badge>
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="h-[70vh]">
|
|
||||||
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
|
<div class="h-[70vh] overflow-auto">
|
||||||
|
<!-- PDF Viewer (browser native) -->
|
||||||
|
<template v-if="viewerType === 'pdf' && props.src">
|
||||||
|
<iframe
|
||||||
|
:src="props.src"
|
||||||
|
class="w-full h-full rounded border"
|
||||||
|
type="application/pdf"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||||||
|
<template v-else-if="viewerType === 'docx'">
|
||||||
|
<!-- Loading/generating state -->
|
||||||
|
<div
|
||||||
|
v-if="previewGenerating"
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-4"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||||||
|
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||||||
|
</div>
|
||||||
|
<!-- Error state -->
|
||||||
|
<div
|
||||||
|
v-else-if="previewError"
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||||
|
>
|
||||||
|
<span>{{ previewError }}</span>
|
||||||
|
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||||
|
Prenesi datoteko
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<!-- Preview ready -->
|
||||||
|
<iframe
|
||||||
|
v-else-if="docxPreviewUrl"
|
||||||
|
:src="docxPreviewUrl"
|
||||||
|
class="w-full h-full rounded border"
|
||||||
|
type="application/pdf"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Image Viewer -->
|
||||||
|
<template v-else-if="viewerType === 'image' && props.src">
|
||||||
|
<img
|
||||||
|
:src="props.src"
|
||||||
|
:alt="props.title"
|
||||||
|
class="max-w-full max-h-full mx-auto object-contain"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Text/CSV/XML Viewer -->
|
||||||
|
<template v-else-if="viewerType === 'text'">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||||
|
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-else
|
||||||
|
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
|
||||||
|
>{{ textContent }}</pre
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Unsupported -->
|
||||||
|
<template v-else-if="viewerType === 'unsupported'">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||||
|
>
|
||||||
|
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||||||
|
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||||
|
Prenesi datoteko
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No source -->
|
||||||
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end mt-4">
|
<div class="flex justify-end mt-4">
|
||||||
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { LucideIcon } from "lucide-vue-next";
|
|
||||||
import { ChevronRight } from "lucide-vue-next";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/Components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
items: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
isActive?: boolean;
|
|
||||||
items?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
<Collapsible
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.title"
|
|
||||||
as-child
|
|
||||||
:default-open="item.isActive"
|
|
||||||
class="group/collapsible"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<CollapsibleTrigger as-child>
|
|
||||||
<SidebarMenuButton :tooltip="item.title">
|
|
||||||
<component :is="item.icon" v-if="item.icon" />
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<ChevronRight
|
|
||||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
|
||||||
/>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<SidebarMenuSub>
|
|
||||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
|
||||||
<SidebarMenuSubButton as-child>
|
|
||||||
<a :href="subItem.url">
|
|
||||||
<span>{{ subItem.title }}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
</template>
|
|
||||||
@@ -7,12 +7,7 @@ import { router } from "@inertiajs/vue3";
|
|||||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||||
import SectionTitle from "../SectionTitle.vue";
|
import SectionTitle from "../SectionTitle.vue";
|
||||||
import {
|
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||||
FormControl,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/Components/ui/form";
|
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -97,7 +92,7 @@ watch(
|
|||||||
country: a.country || "",
|
country: a.country || "",
|
||||||
post_code: a.post_code || a.postal_code || "",
|
post_code: a.post_code || a.postal_code || "",
|
||||||
city: a.city || "",
|
city: a.city || "",
|
||||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||||
description: a.description || "",
|
description: a.description || "",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -108,7 +103,9 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(() => props.show, (val) => {
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(val) => {
|
||||||
if (val && props.edit && props.id) {
|
if (val && props.edit && props.id) {
|
||||||
const a = props.person.addresses?.find((x) => x.id === props.id);
|
const a = props.person.addresses?.find((x) => x.id === props.id);
|
||||||
if (a) {
|
if (a) {
|
||||||
@@ -117,23 +114,21 @@ watch(() => props.show, (val) => {
|
|||||||
country: a.country || "",
|
country: a.country || "",
|
||||||
post_code: a.post_code || a.postal_code || "",
|
post_code: a.post_code || a.postal_code || "",
|
||||||
city: a.city || "",
|
city: a.city || "",
|
||||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||||
description: a.description || "",
|
description: a.description || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (val && !props.edit) {
|
} else if (val && !props.edit) {
|
||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
const { values } = form;
|
const { values } = form;
|
||||||
|
|
||||||
router.post(
|
router.post(route("person.address.create", props.person), values, {
|
||||||
route("person.address.create", props.person),
|
|
||||||
values,
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
@@ -152,8 +147,7 @@ const create = async () => {
|
|||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
@@ -223,7 +217,12 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Naslov</FormLabel>
|
<FormLabel>Naslov</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Naslov"
|
||||||
|
autocomplete="street-address"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -233,7 +232,12 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Država</FormLabel>
|
<FormLabel>Država</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Država"
|
||||||
|
autocomplete="country"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -243,7 +247,12 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Poštna številka</FormLabel>
|
<FormLabel>Poštna številka</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Poštna številka"
|
||||||
|
autocomplete="postal-code"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -253,7 +262,22 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Mesto</FormLabel>
|
<FormLabel>Mesto</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Mesto"
|
||||||
|
autocomplete="address-level2"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Opis</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
|||||||
import { router } from "@inertiajs/vue3";
|
import { router } from "@inertiajs/vue3";
|
||||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||||
import SectionTitle from "../SectionTitle.vue";
|
import SectionTitle from "../SectionTitle.vue";
|
||||||
import {
|
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||||
FormControl,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/Components/ui/form";
|
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -85,7 +80,7 @@ const hydrate = () => {
|
|||||||
country: a.country || "",
|
country: a.country || "",
|
||||||
post_code: a.post_code || a.postal_code || "",
|
post_code: a.post_code || a.postal_code || "",
|
||||||
city: a.city || "",
|
city: a.city || "",
|
||||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
type_id: a.type_id ?? props.types?.[0]?.id ?? null,
|
||||||
description: a.description || "",
|
description: a.description || "",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -94,10 +89,17 @@ const hydrate = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => props.id, () => hydrate(), { immediate: true });
|
watch(
|
||||||
watch(() => props.show, (v) => {
|
() => props.id,
|
||||||
|
() => hydrate(),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(v) => {
|
||||||
if (v) hydrate();
|
if (v) hydrate();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
@@ -157,7 +159,12 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Naslov</FormLabel>
|
<FormLabel>Naslov</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Naslov"
|
||||||
|
autocomplete="street-address"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -167,7 +174,12 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Država</FormLabel>
|
<FormLabel>Država</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Država"
|
||||||
|
autocomplete="country"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -177,7 +189,12 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Poštna številka</FormLabel>
|
<FormLabel>Poštna številka</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Poštna številka"
|
||||||
|
autocomplete="postal-code"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -187,7 +204,22 @@ const onConfirm = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Mesto</FormLabel>
|
<FormLabel>Mesto</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Mesto"
|
||||||
|
autocomplete="address-level2"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Opis</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<Card class="p-2 gap-1" v-for="address in person.addresses" :key="address.id">
|
<Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||||
>
|
>
|
||||||
@@ -61,13 +61,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
|
<p class="font-medium text-gray-900 leading-relaxed p-1">
|
||||||
{{
|
{{
|
||||||
address.post_code && address.city
|
address.post_code && address.city
|
||||||
? `${address.address}, ${address.post_code} ${address.city}`
|
? `${address.address}, ${address.post_code} ${address.city}`
|
||||||
: address.address
|
: address.address
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
|
||||||
|
{{ address.description }}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<button
|
<button
|
||||||
v-if="edit"
|
v-if="edit"
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<template v-if="getEmails(person).length">
|
<template v-if="getEmails(person).length">
|
||||||
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx">
|
<Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
|
||||||
<div class="flex items-center justify-between mb-2" v-if="edit">
|
<div class="flex items-center justify-between" v-if="edit">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
v-if="email?.label"
|
v-if="email?.label"
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||||
@@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<p class="text-sm font-medium text-gray-900 leading-relaxed">
|
<p class="font-medium text-gray-900 leading-relaxed">
|
||||||
{{ email?.value || email?.email || email?.address || "-" }}
|
{{ email?.value || email?.email || email?.address || "-" }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Tabs v-model="activeTab" class="mt-2">
|
<Tabs v-model="activeTab" class="mt-2">
|
||||||
<TabsList class="flex w-full bg-white gap-2 p-1">
|
<TabsList class="flex flex-row flex-wrap bg-white gap-2 p-1">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="person"
|
value="person"
|
||||||
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
|
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
|
||||||
@@ -384,6 +384,7 @@ const switchToTab = (tab) => {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="person" class="py-2">
|
<TabsContent value="person" class="py-2">
|
||||||
<PersonInfoPersonTab
|
<PersonInfoPersonTab
|
||||||
|
:is-client-case="clientCaseUuid ? true : false"
|
||||||
:person="person"
|
:person="person"
|
||||||
:edit="edit"
|
:edit="edit"
|
||||||
:person-edit="personEdit"
|
:person-edit="personEdit"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { UserEditIcon } from "@/Utilities/Icons";
|
import { UserEditIcon } from "@/Utilities/Icons";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
import { fmtDateDMY } from "@/Utilities/functions";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
person: Object,
|
person: Object,
|
||||||
|
isClientCase: { type: Boolean, default: false },
|
||||||
edit: { type: Boolean, default: true },
|
edit: { type: Boolean, default: true },
|
||||||
personEdit: { type: Boolean, default: true },
|
personEdit: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['edit']);
|
const emit = defineEmits(["edit"]);
|
||||||
|
|
||||||
const getMainAddress = (adresses) => {
|
const getMainAddress = (adresses) => {
|
||||||
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
|
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
|
||||||
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
emit('edit');
|
emit("edit");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,51 +46,126 @@ const handleEdit = () => {
|
|||||||
>
|
>
|
||||||
<UserEditIcon size="md" />
|
<UserEditIcon size="md" />
|
||||||
<span>Uredi</span>
|
<span>Uredi</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Primer ref.
|
||||||
|
</p>
|
||||||
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
|
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p>
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Naziv</p>
|
||||||
<p class="text-sm font-semibold text-gray-900">
|
<p class="text-sm font-semibold text-gray-900">
|
||||||
{{ person.full_name }}
|
{{ person.full_name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p>
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Davčna
|
||||||
|
</p>
|
||||||
<p class="text-sm font-semibold text-gray-900">
|
<p class="text-sm font-semibold text-gray-900">
|
||||||
{{ person.tax_number }}
|
{{ person.tax_number }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p>
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Emšo</p>
|
||||||
<p class="text-sm font-semibold text-gray-900">
|
<p class="text-sm font-semibold text-gray-900">
|
||||||
{{ person.social_security_number }}
|
{{ person.social_security_number }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
|
<div
|
||||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
v-if="isClientCase"
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p>
|
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Naslov
|
||||||
|
</p>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-medium text-gray-900">
|
||||||
{{ getMainAddress(person.addresses) }}
|
{{ getMainAddress(person.addresses) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p>
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Telefon
|
||||||
|
</p>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-medium text-gray-900">
|
||||||
{{ getMainPhone(person.phones) }}
|
{{ getMainPhone(person.phones) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p>
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Dat. rojstva
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ fmtDateDMY(person.birthday) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div
|
||||||
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Naslov
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ getMainAddress(person.addresses) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Telefon
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ getMainPhone(person.phones) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3"
|
||||||
|
:class="[isClientCase ? 'md:grid-cols-2' : '']"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isClientCase"
|
||||||
|
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
|
||||||
|
Delodajalec
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ person.employer }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="md:col-span-full rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
:class="[isClientCase ? 'lg:col-span-1' : '']"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Opis</p>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-medium text-gray-900">
|
||||||
{{ person.description }}
|
{{ person.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
} from "@/Components/ui/dropdown-menu";
|
} from "@/Components/ui/dropdown-menu";
|
||||||
import { Card } from "@/Components/ui/card";
|
import { Card } from "@/Components/ui/card";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
|
import {
|
||||||
|
CircleCheckBigIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
EllipsisVertical,
|
||||||
|
MessageSquare,
|
||||||
|
MessageSquareText,
|
||||||
|
} from "lucide-vue-next";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<template v-if="getPhones(person).length">
|
<template v-if="getPhones(person).length">
|
||||||
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id">
|
<Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||||
>
|
>
|
||||||
@@ -79,8 +85,12 @@ const handleSms = (phone) => emit("sms", phone);
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1">
|
<p class="font-medium leading-relaxed p-1 flex gap-1 items-center">
|
||||||
{{ phone.nu }}
|
{{ phone.nu }}
|
||||||
|
<CircleCheckBigIcon color="#3e9392" size="20" v-if="phone.validated" />
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
|
||||||
|
{{ phone.description }}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -301,28 +302,14 @@ const updateSmsFromSelection = async () => {
|
|||||||
const url = route("clientCase.sms.preview", {
|
const url = route("clientCase.sms.preview", {
|
||||||
client_case: props.clientCaseUuid,
|
client_case: props.clientCaseUuid,
|
||||||
});
|
});
|
||||||
const res = await fetch(url, {
|
const { data } = await axios.post(url, {
|
||||||
method: "POST",
|
|
||||||
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,
|
template_id: form.values.template_id,
|
||||||
contract_uuid: form.values.contract_uuid || null,
|
contract_uuid: form.values.contract_uuid || null,
|
||||||
}),
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||||
form.setFieldValue("message", data.content);
|
form.setFieldValue("message", data.content);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore and fallback
|
// ignore and fallback
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,72 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
|
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
|
||||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||||
import { useForm, Field as FormField } from "vee-validate";
|
import { useForm, Field as FormField } from "vee-validate";
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from "@inertiajs/vue3";
|
||||||
import { ref } from 'vue';
|
import { ref } from "vue";
|
||||||
import {
|
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||||
FormControl,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/Components/ui/form";
|
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Textarea } from "@/Components/ui/textarea";
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import DatePicker from "../DatePicker.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
person: Object
|
person: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
const processingUpdate = ref(false);
|
const processingUpdate = ref(false);
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
z.object({
|
z.object({
|
||||||
full_name: z.string().min(1, "Naziv je obvezen."),
|
full_name: z.string().min(1, "Naziv je obvezen."),
|
||||||
tax_number: z.string().optional(),
|
tax_number: z.string().optional(),
|
||||||
social_security_number: z.string().optional(),
|
social_security_number: z.string().optional(),
|
||||||
|
birthday: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
employer: z.string().optional(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validationSchema: formSchema,
|
validationSchema: formSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
full_name: props.person?.full_name || '',
|
full_name: props.person?.full_name || "",
|
||||||
tax_number: props.person?.tax_number || '',
|
tax_number: props.person?.tax_number || "",
|
||||||
social_security_number: props.person?.social_security_number || '',
|
social_security_number: props.person?.social_security_number || "",
|
||||||
description: props.person?.description || ''
|
birthday: props.person?.birthday || "",
|
||||||
|
description: props.person?.description || "",
|
||||||
|
employer: props.person?.employer || "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
emit('close');
|
emit("close");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
form.resetForm({
|
form.resetForm({
|
||||||
values: {
|
values: {
|
||||||
full_name: props.person?.full_name || '',
|
full_name: props.person?.full_name || "",
|
||||||
tax_number: props.person?.tax_number || '',
|
tax_number: props.person?.tax_number || "",
|
||||||
social_security_number: props.person?.social_security_number || '',
|
social_security_number: props.person?.social_security_number || "",
|
||||||
description: props.person?.description || ''
|
birthday: props.person?.birthday || "",
|
||||||
}
|
description: props.person?.description || "",
|
||||||
|
employer: props.person?.employer || "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updatePerson = async () => {
|
const updatePerson = async () => {
|
||||||
processingUpdate.value = true;
|
processingUpdate.value = true;
|
||||||
const { values } = form;
|
const { values } = form;
|
||||||
|
|
||||||
router.put(
|
router.put(route("person.update", props.person), values, {
|
||||||
route('person.update', props.person),
|
|
||||||
values,
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
processingUpdate.value = false;
|
processingUpdate.value = false;
|
||||||
@@ -86,9 +85,8 @@ const updatePerson = async () => {
|
|||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
processingUpdate.value = false;
|
processingUpdate.value = false;
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(() => {
|
const onSubmit = form.handleSubmit(() => {
|
||||||
updatePerson();
|
updatePerson();
|
||||||
@@ -96,7 +94,7 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
|
|
||||||
const onConfirm = () => {
|
const onConfirm = () => {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
@@ -109,9 +107,7 @@ const onConfirm = () => {
|
|||||||
>
|
>
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
<SectionTitle class="border-b mb-4">
|
<SectionTitle class="border-b mb-4">
|
||||||
<template #title>
|
<template #title> Oseba </template>
|
||||||
Oseba
|
|
||||||
</template>
|
|
||||||
</SectionTitle>
|
</SectionTitle>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -163,15 +159,42 @@ const onConfirm = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</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">
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Opis</FormLabel>
|
<FormLabel>Opis</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea id="cdescription" placeholder="Opis" v-bind="componentField" />
|
||||||
id="cdescription"
|
|
||||||
placeholder="Opis"
|
|
||||||
v-bind="componentField"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
|||||||
import { router } from "@inertiajs/vue3";
|
import { router } from "@inertiajs/vue3";
|
||||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||||
import SectionTitle from "../SectionTitle.vue";
|
import SectionTitle from "../SectionTitle.vue";
|
||||||
import {
|
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||||
FormControl,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/Components/ui/form";
|
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -101,10 +96,7 @@ const create = async () => {
|
|||||||
processing.value = true;
|
processing.value = true;
|
||||||
const { values } = form;
|
const { values } = form;
|
||||||
|
|
||||||
router.post(
|
router.post(route("person.phone.create", props.person), values, {
|
||||||
route("person.phone.create", props.person),
|
|
||||||
values,
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
close();
|
close();
|
||||||
@@ -122,8 +114,7 @@ const create = async () => {
|
|||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(() => {
|
const onSubmit = form.handleSubmit(() => {
|
||||||
@@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Številka</FormLabel>
|
<FormLabel>Številka</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Številka telefona"
|
||||||
|
autocomplete="tel"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
|
<SelectItem
|
||||||
|
v-for="option in countryOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
|
<SelectItem
|
||||||
|
v-for="option in phoneTypeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Opis</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField v-slot="{ value, handleChange }" name="validated">
|
<FormField v-slot="{ value, handleChange }" name="validated">
|
||||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import * as z from "zod";
|
|||||||
import { router } from "@inertiajs/vue3";
|
import { router } from "@inertiajs/vue3";
|
||||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||||
import SectionTitle from "../SectionTitle.vue";
|
import SectionTitle from "../SectionTitle.vue";
|
||||||
import {
|
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||||
FormControl,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/Components/ui/form";
|
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -108,7 +103,7 @@ function hydrateFromProps() {
|
|||||||
form.setValues({
|
form.setValues({
|
||||||
nu: p.nu || "",
|
nu: p.nu || "",
|
||||||
country_code: p.country_code ?? 386,
|
country_code: p.country_code ?? 386,
|
||||||
type_id: p.type_id ?? (props.types?.[0]?.id ?? null),
|
type_id: p.type_id ?? props.types?.[0]?.id ?? null,
|
||||||
description: p.description || "",
|
description: p.description || "",
|
||||||
validated: !!p.validated,
|
validated: !!p.validated,
|
||||||
phone_type: p.phone_type ?? null,
|
phone_type: p.phone_type ?? null,
|
||||||
@@ -119,8 +114,17 @@ function hydrateFromProps() {
|
|||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.id, () => hydrateFromProps(), { immediate: true });
|
watch(
|
||||||
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
|
() => props.id,
|
||||||
|
() => hydrateFromProps(),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(val) => {
|
||||||
|
if (val) hydrateFromProps();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
@@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Številka</FormLabel>
|
<FormLabel>Številka</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Številka telefona"
|
||||||
|
autocomplete="tel"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
|
<SelectItem
|
||||||
|
v-for="option in countryOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
|
<SelectItem
|
||||||
|
v-for="option in phoneTypeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Opis</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" placeholder="Opis" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField v-slot="{ value, handleChange }" name="validated">
|
<FormField v-slot="{ value, handleChange }" name="validated">
|
||||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup>
|
||||||
|
import { CalendarIcon, XIcon } from "lucide-vue-next";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||||
|
import { RangeCalendar } from "@/Components/ui/range-calendar";
|
||||||
|
import {
|
||||||
|
DateFormatter,
|
||||||
|
getLocalTimeZone,
|
||||||
|
today,
|
||||||
|
parseDate,
|
||||||
|
CalendarDate,
|
||||||
|
} from "@internationalized/date";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ start: null, end: null }),
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "Izberi datumski obseg",
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
buttonClass: {
|
||||||
|
type: String,
|
||||||
|
default: "w-[280px]",
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: String,
|
||||||
|
default: "sl-SI",
|
||||||
|
},
|
||||||
|
numberOfMonths: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
minValue: {
|
||||||
|
type: Object,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
maxValue: {
|
||||||
|
type: Object,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
clearable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
const df = new DateFormatter(props.locale, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if there's a selected value
|
||||||
|
const hasValue = computed(() => {
|
||||||
|
const val = props.modelValue;
|
||||||
|
return val?.start || val?.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert string dates to CalendarDate objects for the calendar
|
||||||
|
const calendarValue = computed({
|
||||||
|
get() {
|
||||||
|
const val = props.modelValue;
|
||||||
|
if (!val) return undefined;
|
||||||
|
|
||||||
|
let start = null;
|
||||||
|
let end = null;
|
||||||
|
|
||||||
|
if (val.start) {
|
||||||
|
if (typeof val.start === "string") {
|
||||||
|
start = parseDate(val.start);
|
||||||
|
} else if (val.start instanceof CalendarDate) {
|
||||||
|
start = val.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val.end) {
|
||||||
|
if (typeof val.end === "string") {
|
||||||
|
end = parseDate(val.end);
|
||||||
|
} else if (val.end instanceof CalendarDate) {
|
||||||
|
end = val.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start && !end) return undefined;
|
||||||
|
return { start, end };
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
if (!newValue) {
|
||||||
|
emit("update:modelValue", { start: null, end: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CalendarDate to ISO string (YYYY-MM-DD) for easier handling
|
||||||
|
const result = {
|
||||||
|
start: newValue.start ? newValue.start.toString() : null,
|
||||||
|
end: newValue.end ? newValue.end.toString() : null,
|
||||||
|
};
|
||||||
|
emit("update:modelValue", result);
|
||||||
|
|
||||||
|
// Close popover when both dates are selected
|
||||||
|
if (result.start && result.end) {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
const val = calendarValue.value;
|
||||||
|
if (!val?.start) return props.placeholder;
|
||||||
|
|
||||||
|
const startFormatted = df.format(val.start.toDate(getLocalTimeZone()));
|
||||||
|
if (!val.end) return startFormatted;
|
||||||
|
|
||||||
|
const endFormatted = df.format(val.end.toDate(getLocalTimeZone()));
|
||||||
|
return `${startFormatted} - ${endFormatted}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearValue(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
emit("update:modelValue", { start: null, end: null });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover v-model:open="open">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'justify-start text-left font-normal',
|
||||||
|
!calendarValue?.start && 'text-muted-foreground',
|
||||||
|
buttonClass
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
|
||||||
|
<span class="truncate flex-1">{{ displayText }}</span>
|
||||||
|
<span
|
||||||
|
v-if="clearable && hasValue && !disabled"
|
||||||
|
class="ml-2 shrink-0 opacity-50 hover:opacity-100 cursor-pointer"
|
||||||
|
@click.stop.prevent="clearValue"
|
||||||
|
>
|
||||||
|
<XIcon class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-0" align="start">
|
||||||
|
<RangeCalendar
|
||||||
|
v-model="calendarValue"
|
||||||
|
:locale="locale"
|
||||||
|
:number-of-months="numberOfMonths"
|
||||||
|
:min-value="minValue"
|
||||||
|
:max-value="maxValue"
|
||||||
|
initial-focus
|
||||||
|
@update:start-value="
|
||||||
|
(startDate) => {
|
||||||
|
if (calendarValue?.start?.toString() !== startDate?.toString()) {
|
||||||
|
calendarValue = { start: startDate, end: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { SidebarProps } from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AudioWaveform,
|
|
||||||
BookOpen,
|
|
||||||
Bot,
|
|
||||||
Command,
|
|
||||||
Frame,
|
|
||||||
GalleryVerticalEnd,
|
|
||||||
Map,
|
|
||||||
PieChart,
|
|
||||||
Settings2,
|
|
||||||
SquareTerminal,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import NavMain from "@/Components/app/ui/layout/NavMain.vue";
|
|
||||||
import NavProjects from "@/Components/app/ui/layout/NavProjects.vue";
|
|
||||||
import NavUser from "@/Components/app/ui/layout/NavUser.vue";
|
|
||||||
import TeamSwitcher from "@/Components/app/ui/layout/TeamSwitcher.vue";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarRail,
|
|
||||||
} from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
|
||||||
collapsible: "icon",
|
|
||||||
});
|
|
||||||
|
|
||||||
// This is sample data.
|
|
||||||
const data = {
|
|
||||||
user: {
|
|
||||||
name: "shadcn",
|
|
||||||
email: "m@example.com",
|
|
||||||
avatar: "/avatars/shadcn.jpg",
|
|
||||||
},
|
|
||||||
teams: [
|
|
||||||
{
|
|
||||||
name: "Acme Inc",
|
|
||||||
logo: GalleryVerticalEnd,
|
|
||||||
plan: "Enterprise",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Acme Corp.",
|
|
||||||
logo: AudioWaveform,
|
|
||||||
plan: "Startup",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Evil Corp.",
|
|
||||||
logo: Command,
|
|
||||||
plan: "Free",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "Playground",
|
|
||||||
url: "#",
|
|
||||||
icon: SquareTerminal,
|
|
||||||
isActive: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "History",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Starred",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Models",
|
|
||||||
url: "#",
|
|
||||||
icon: Bot,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Genesis",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Explorer",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Quantum",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Documentation",
|
|
||||||
url: "#",
|
|
||||||
icon: BookOpen,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Introduction",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Get Started",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tutorials",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Changelog",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
url: "#",
|
|
||||||
icon: Settings2,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "General",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Team",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Billing",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Limits",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "Design Engineering",
|
|
||||||
url: "#",
|
|
||||||
icon: Frame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sales & Marketing",
|
|
||||||
url: "#",
|
|
||||||
icon: PieChart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Travel",
|
|
||||||
url: "#",
|
|
||||||
icon: Map,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Sidebar v-bind="props">
|
|
||||||
<SidebarHeader>
|
|
||||||
<TeamSwitcher :teams="data.teams" />
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
<NavMain :items="data.navMain" />
|
|
||||||
<NavProjects :projects="data.projects" />
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarFooter>
|
|
||||||
<NavUser :user="data.user" />
|
|
||||||
</SidebarFooter>
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
</template>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { LucideIcon } from "lucide-vue-next";
|
|
||||||
import { ChevronRight } from "lucide-vue-next";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/Components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
items: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
isActive?: boolean;
|
|
||||||
items?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
<Collapsible
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.title"
|
|
||||||
as-child
|
|
||||||
:default-open="item.isActive"
|
|
||||||
class="group/collapsible"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<CollapsibleTrigger as-child>
|
|
||||||
<SidebarMenuButton :tooltip="item.title">
|
|
||||||
<component :is="item.icon" v-if="item.icon" />
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<ChevronRight
|
|
||||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
|
||||||
/>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<SidebarMenuSub>
|
|
||||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
|
||||||
<SidebarMenuSubButton as-child>
|
|
||||||
<a :href="subItem.url">
|
|
||||||
<span>{{ subItem.title }}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
</template>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { LucideIcon } from "lucide-vue-next";
|
|
||||||
import { Folder, Forward, MoreHorizontal, Trash2 } from "lucide-vue-next";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/Components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
projects: {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { isMobile } = useSidebar();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
|
||||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem v-for="item in projects" :key="item.name">
|
|
||||||
<SidebarMenuButton as-child>
|
|
||||||
<a :href="item.url">
|
|
||||||
<component :is="item.icon" />
|
|
||||||
<span>{{ item.name }}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<SidebarMenuAction show-on-hover>
|
|
||||||
<MoreHorizontal />
|
|
||||||
<span class="sr-only">More</span>
|
|
||||||
</SidebarMenuAction>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
class="w-48 rounded-lg"
|
|
||||||
:side="isMobile ? 'bottom' : 'right'"
|
|
||||||
:align="isMobile ? 'end' : 'start'"
|
|
||||||
>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Folder class="text-muted-foreground" />
|
|
||||||
<span>View Project</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Forward class="text-muted-foreground" />
|
|
||||||
<span>Share Project</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Trash2 class="text-muted-foreground" />
|
|
||||||
<span>Delete Project</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton class="text-sidebar-foreground/70">
|
|
||||||
<MoreHorizontal class="text-sidebar-foreground/70" />
|
|
||||||
<span>More</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
</template>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
BadgeCheck,
|
|
||||||
Bell,
|
|
||||||
ChevronsUpDown,
|
|
||||||
CreditCard,
|
|
||||||
LogOut,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/Components/ui/avatar";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/Components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
user: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { isMobile } = useSidebar();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
|
||||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
|
||||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span class="truncate font-medium">{{ user.name }}</span>
|
|
||||||
<span class="truncate text-xs">{{ user.email }}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown class="ml-auto size-4" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
|
||||||
:side="isMobile ? 'bottom' : 'right'"
|
|
||||||
align="end"
|
|
||||||
:side-offset="4"
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel class="p-0 font-normal">
|
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
|
||||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
|
||||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
|
||||||
<span class="truncate text-xs">{{ user.email }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Sparkles />
|
|
||||||
Upgrade to Pro
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<BadgeCheck />
|
|
||||||
Account
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<CreditCard />
|
|
||||||
Billing
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Bell />
|
|
||||||
Notifications
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<LogOut />
|
|
||||||
Log out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</template>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Component } from "vue";
|
|
||||||
|
|
||||||
import { ChevronsUpDown, Plus } from "lucide-vue-next";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/Components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/Components/ui/sidebar";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
teams: {
|
|
||||||
name: string;
|
|
||||||
logo: Component;
|
|
||||||
plan: string;
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { isMobile } = useSidebar();
|
|
||||||
const activeTeam = ref(props.teams[0]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
|
||||||
>
|
|
||||||
<component :is="activeTeam.logo" class="size-4" />
|
|
||||||
</div>
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span class="truncate font-medium">
|
|
||||||
{{ activeTeam.name }}
|
|
||||||
</span>
|
|
||||||
<span class="truncate text-xs">{{ activeTeam.plan }}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown class="ml-auto" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
|
||||||
align="start"
|
|
||||||
:side="isMobile ? 'bottom' : 'right'"
|
|
||||||
:side-offset="4"
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
|
||||||
Teams
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="(team, index) in teams"
|
|
||||||
:key="team.name"
|
|
||||||
class="gap-2 p-2"
|
|
||||||
@click="activeTeam = team"
|
|
||||||
>
|
|
||||||
<div class="flex size-6 items-center justify-center rounded-sm border">
|
|
||||||
<component :is="team.logo" class="size-3.5 shrink-0" />
|
|
||||||
</div>
|
|
||||||
{{ team.name }}
|
|
||||||
<DropdownMenuShortcut>⌘{{ index + 1 }}</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem class="gap-2 p-2">
|
|
||||||
<div
|
|
||||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
|
||||||
>
|
|
||||||
<Plus class="size-4" />
|
|
||||||
</div>
|
|
||||||
<div class="font-medium text-muted-foreground">Add team</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fieldVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
orientation: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
:data-orientation="orientation"
|
||||||
|
:class="cn(fieldVariants({ orientation }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||||
|
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||||
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
errors: { type: Array, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = computed(() => {
|
||||||
|
if (!props.errors || props.errors.length === 0) return null;
|
||||||
|
|
||||||
|
if (props.errors.length === 1 && props.errors[0]?.message) {
|
||||||
|
return props.errors[0].message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.errors.some((e) => e?.message) ? props.errors : null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="$slots.default || content"
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
:class="cn('text-destructive text-sm font-normal', props.class)"
|
||||||
|
>
|
||||||
|
<slot v-if="$slots.default" />
|
||||||
|
|
||||||
|
<template v-else-if="typeof content === 'string'">
|
||||||
|
{{ content }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-else-if="Array.isArray(content)"
|
||||||
|
class="ml-4 flex list-disc flex-col gap-1"
|
||||||
|
>
|
||||||
|
<li v-for="(error, index) in content" :key="index">
|
||||||
|
{{ error?.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||||
|
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
|
||||||
|
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
variant: { type: String, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
:data-variant="variant"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'mb-3 font-medium',
|
||||||
|
'data-[variant=legend]:text-base',
|
||||||
|
'data-[variant=label]:text-sm',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</legend>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Separator } from '@/Components/ui/separator';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
:data-content="!!$slots.default"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Separator class="absolute inset-0 top-1/2" />
|
||||||
|
<span
|
||||||
|
v-if="$slots.default"
|
||||||
|
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export { default as Field } from "./Field.vue";
|
||||||
|
export { default as FieldContent } from "./FieldContent.vue";
|
||||||
|
export { default as FieldDescription } from "./FieldDescription.vue";
|
||||||
|
export { default as FieldError } from "./FieldError.vue";
|
||||||
|
export { default as FieldGroup } from "./FieldGroup.vue";
|
||||||
|
export { default as FieldLabel } from "./FieldLabel.vue";
|
||||||
|
export { default as FieldLegend } from "./FieldLegend.vue";
|
||||||
|
export { default as FieldSeparator } from "./FieldSeparator.vue";
|
||||||
|
export { default as FieldSet } from "./FieldSet.vue";
|
||||||
|
export { default as FieldTitle } from "./FieldTitle.vue";
|
||||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
|||||||
reference: { type: null, required: false },
|
reference: { type: null, required: false },
|
||||||
asChild: { type: Boolean, required: false },
|
asChild: { type: Boolean, required: false },
|
||||||
as: { type: null, required: false },
|
as: { type: null, required: false },
|
||||||
|
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||||
class: { type: null, required: false },
|
class: { type: null, required: false },
|
||||||
});
|
});
|
||||||
const emits = defineEmits([
|
const emits = defineEmits([
|
||||||
|
|||||||
@@ -251,19 +251,17 @@ function isActive(patterns) {
|
|||||||
: 'sticky top-0 h-screen overflow-y-auto',
|
: 'sticky top-0 h-screen overflow-y-auto',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar">
|
||||||
class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
:href="route('dashboard')"
|
:href="route('dashboard')"
|
||||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<ApplicationMark />
|
<ApplicationMark />
|
||||||
<span
|
<span
|
||||||
v-if="!sidebarCollapsed"
|
v-if="!sidebarCollapsed"
|
||||||
class="text-sm font-semibold text-gray-900 transition-opacity"
|
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
|
||||||
>
|
>
|
||||||
Admin
|
Administrator
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,19 +10,6 @@ import GlobalSearch from "./Partials/GlobalSearch.vue";
|
|||||||
import NotificationsBell from "./Partials/NotificationsBell.vue";
|
import NotificationsBell from "./Partials/NotificationsBell.vue";
|
||||||
import ToastContainer from "@/Components/Toast/ToastContainer.vue";
|
import ToastContainer from "@/Components/Toast/ToastContainer.vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
||||||
import {
|
|
||||||
faMobileScreenButton,
|
|
||||||
faGaugeHigh,
|
|
||||||
faLayerGroup,
|
|
||||||
faUserGroup,
|
|
||||||
faFolderOpen,
|
|
||||||
faFileImport,
|
|
||||||
faTableList,
|
|
||||||
faFileCirclePlus,
|
|
||||||
faMap,
|
|
||||||
faGear,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { MenuIcon } from "lucide-vue-next";
|
import { MenuIcon } from "lucide-vue-next";
|
||||||
import { SearchIcon } from "lucide-vue-next";
|
import { SearchIcon } from "lucide-vue-next";
|
||||||
import { ChevronDownIcon } from "lucide-vue-next";
|
import { ChevronDownIcon } from "lucide-vue-next";
|
||||||
@@ -310,18 +297,18 @@ function isActive(patterns) {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-16 px-4 flex items-center justify-between border-b border-sidebar-border bg-sidebar"
|
class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
:href="route('dashboard')"
|
:href="route('dashboard')"
|
||||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<ApplicationMark />
|
<ApplicationMark />
|
||||||
<span
|
<span
|
||||||
v-if="!sidebarCollapsed"
|
v-if="!sidebarCollapsed"
|
||||||
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
|
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
|
||||||
>
|
>
|
||||||
Teren
|
Aplikacija
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,14 +149,14 @@ const closeSearch = () => (searchOpen.value = false);
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
:href="route('phone.index')"
|
:href="route('phone.index')"
|
||||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
class="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<ApplicationMark />
|
<ApplicationMark />
|
||||||
<span
|
<span
|
||||||
v-if="showLabels"
|
v-if="showLabels"
|
||||||
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
|
class="text-lg font-semibold text-sidebar-foreground transition-opacity"
|
||||||
>
|
>
|
||||||
Teren
|
Mobitel
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/Components/ui/card";
|
||||||
|
import { Separator } from "@/Components/ui/separator";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/Components/ui/dialog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import { SearchIcon } from "@/Utilities/Icons";
|
import { SearchIcon, XIcon } from "lucide-vue-next";
|
||||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import { Link } from "@inertiajs/vue3";
|
import { Link } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -55,139 +65,114 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
|
|||||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
|
||||||
<transition name="fade">
|
<DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
|
||||||
<div v-if="isOpen" class="fixed inset-0 z-50">
|
<div class="p-4 border-b" ref="inputWrap">
|
||||||
<div
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="p-4 border-b border-slate-200/60"
|
|
||||||
ref="inputWrap"
|
|
||||||
>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<SearchIcon
|
||||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
|
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||||
<SearchIcon />
|
/>
|
||||||
</div>
|
|
||||||
<Input
|
<Input
|
||||||
v-model="query"
|
v-model="query"
|
||||||
placeholder="Išči po naročnikih ali primerih (Ctrl+K za zapiranje)"
|
placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
|
||||||
class="w-full pl-10 pr-16 rounded-xl"
|
class="w-full pl-10 pr-16"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="query"
|
v-if="query"
|
||||||
@click="query = ''"
|
@click="query = ''"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-700"
|
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-accent"
|
||||||
>
|
>
|
||||||
ESC
|
<XIcon class="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="max-h-[65vh] overflow-y-auto">
|
||||||
<div
|
|
||||||
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="!query"
|
v-if="!query"
|
||||||
class="p-8 text-sm text-slate-500 text-center space-y-2"
|
class="p-8 text-sm text-muted-foreground text-center space-y-2"
|
||||||
>
|
>
|
||||||
<p>Začni tipkati za iskanje.</p>
|
<p>Začni tipkati za iskanje.</p>
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
Namig: uporabi
|
Namig: uporabi <Badge variant="secondary" class="font-mono">Ctrl</Badge> +
|
||||||
<kbd
|
<Badge variant="secondary" class="font-mono">K</Badge>
|
||||||
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
|
|
||||||
>Ctrl</kbd
|
|
||||||
>
|
|
||||||
+
|
|
||||||
<kbd
|
|
||||||
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
|
|
||||||
>K</kbd
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="divide-y divide-slate-200/70">
|
<div v-else class="space-y-4 p-4">
|
||||||
<div v-if="result.clients.length" class="py-3">
|
<!-- Clients Results -->
|
||||||
|
<div v-if="result.clients.length">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
|
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>Naročniki</span>
|
<span>Naročniki</span>
|
||||||
<span
|
<Badge variant="secondary">{{ result.clients.length }}</Badge>
|
||||||
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
|
|
||||||
>{{ result.clients.length }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<ul role="list" class="px-2 space-y-1">
|
<div class="space-y-1">
|
||||||
<li v-for="client in result.clients" :key="client.client_uuid">
|
|
||||||
<Link
|
<Link
|
||||||
|
v-for="client in result.clients"
|
||||||
|
:key="client.client_uuid"
|
||||||
:href="route('client.show', { uuid: 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-indigo-50/70 transition"
|
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-accent transition"
|
||||||
@click="isOpen = false"
|
@click="isOpen = false"
|
||||||
>
|
>
|
||||||
<span
|
<Badge
|
||||||
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"
|
variant="outline"
|
||||||
>C</span
|
class="shrink-0 w-6 h-6 flex items-center justify-center"
|
||||||
>
|
>C</Badge
|
||||||
<span
|
|
||||||
class="text-slate-700 group-hover:text-slate-900"
|
|
||||||
>{{ client.full_name }}</span
|
|
||||||
>
|
>
|
||||||
|
<span class="font-medium">{{ client.full_name }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="result.client_cases.length" class="py-3">
|
</div>
|
||||||
|
|
||||||
|
<Separator v-if="result.clients.length && result.client_cases.length" />
|
||||||
|
|
||||||
|
<!-- Client Cases Results -->
|
||||||
|
<div v-if="result.client_cases.length">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
|
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>Primeri</span>
|
<span>Primeri</span>
|
||||||
<span
|
<Badge variant="secondary">{{ result.client_cases.length }}</Badge>
|
||||||
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
|
|
||||||
>{{ result.client_cases.length }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<ul role="list" class="px-2 space-y-1">
|
<div class="space-y-2">
|
||||||
<li
|
<Card
|
||||||
v-for="clientcase in result.client_cases"
|
v-for="clientcase in result.client_cases"
|
||||||
:key="clientcase.case_uuid"
|
: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"
|
class="hover:shadow-md transition p-0"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<CardContent class="p-3 space-y-2">
|
||||||
|
<div class="space-y-1">
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
route('clientCase.show', {
|
route('clientCase.show', {
|
||||||
client_case: clientcase.case_uuid,
|
client_case: clientcase.case_uuid,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="text-left font-medium hover:underline leading-tight text-slate-800"
|
class="text-sm font-medium hover:underline block"
|
||||||
@click="isOpen = false"
|
@click="isOpen = false"
|
||||||
>
|
>
|
||||||
{{ clientcase.full_name }}
|
{{ clientcase.full_name }}
|
||||||
</Link>
|
</Link>
|
||||||
<template v-if="clientcase.contract_reference">
|
<div
|
||||||
<span
|
v-if="clientcase.client_full_name"
|
||||||
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"
|
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 }}
|
{{ clientcase.contract_reference }}
|
||||||
</span>
|
</Badge>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
clientcase.contract_segments &&
|
clientcase.contract_segments && clientcase.contract_segments.length
|
||||||
clientcase.contract_segments.length
|
|
||||||
"
|
"
|
||||||
class="flex flex-wrap gap-1 mt-1"
|
class="flex flex-wrap gap-1"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
v-for="seg in clientcase.contract_segments"
|
v-for="seg in clientcase.contract_segments"
|
||||||
@@ -199,17 +184,18 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
|||||||
'?segment=' +
|
'?segment=' +
|
||||||
(seg.id || seg)
|
(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"
|
@click="isOpen = false"
|
||||||
>
|
>
|
||||||
|
<Badge variant="secondary" class="text-xs uppercase">
|
||||||
{{ seg.name || seg }}
|
{{ seg.name || seg }}
|
||||||
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="
|
v-else-if="
|
||||||
clientcase.case_segments && clientcase.case_segments.length
|
clientcase.case_segments && clientcase.case_segments.length
|
||||||
"
|
"
|
||||||
class="flex flex-wrap gap-1 mt-1"
|
class="flex flex-wrap gap-1"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
v-for="seg in clientcase.case_segments"
|
v-for="seg in clientcase.case_segments"
|
||||||
@@ -221,37 +207,27 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
|||||||
'?segment=' +
|
'?segment=' +
|
||||||
(seg.id || seg)
|
(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"
|
@click="isOpen = false"
|
||||||
>
|
>
|
||||||
|
<Badge variant="outline" class="text-xs uppercase">
|
||||||
{{ seg.name }}
|
{{ seg.name }}
|
||||||
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</CardContent>
|
||||||
</ul>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Results -->
|
||||||
<div
|
<div
|
||||||
v-if="!result.clients.length && !result.client_cases.length"
|
v-if="!result.clients.length && !result.client_cases.length"
|
||||||
class="p-8 text-center text-sm text-slate-500"
|
class="p-8 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
Ni rezultatov.
|
Ni rezultatov.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { usePage, Link, router } from "@inertiajs/vue3";
|
import { usePage, Link, router } from "@inertiajs/vue3";
|
||||||
import Dropdown from "@/Components/Dropdown.vue";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
||||||
import { faBell } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { BellIcon } from "lucide-vue-next";
|
import { BellIcon } from "lucide-vue-next";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/Components/ui/separator";
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const due = computed(
|
const due = computed(
|
||||||
@@ -83,12 +83,8 @@ function markRead(item) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown
|
<Popover>
|
||||||
align="right"
|
<PopoverTrigger as-child>
|
||||||
width="72"
|
|
||||||
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
|
|
||||||
>
|
|
||||||
<template #trigger>
|
|
||||||
<Button variant="ghost" size="default" class="relative">
|
<Button variant="ghost" size="default" class="relative">
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
|
|
||||||
@@ -100,32 +96,30 @@ function markRead(item) {
|
|||||||
{{ count }}
|
{{ count }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<template #content>
|
<PopoverContent align="end" class="w-96 p-0">
|
||||||
<div
|
<div class="px-4 py-3 flex items-center justify-between border-b">
|
||||||
class="px-3 py-2 text-xs text-gray-400 border-b sticky top-0 bg-white z-10 flex items-center justify-between"
|
<span class="text-sm font-medium">Zapadejo danes</span>
|
||||||
>
|
|
||||||
<span>Zapadejo danes</span>
|
|
||||||
<Link
|
<Link
|
||||||
:href="route('notifications.unread')"
|
:href="route('notifications.unread')"
|
||||||
class="text-indigo-600 hover:text-indigo-700"
|
class="text-sm text-primary hover:underline"
|
||||||
>Vsa obvestila</Link
|
>Vsa obvestila</Link
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<!-- Scrollable content area with max height -->
|
|
||||||
<div class="max-h-80 overflow-auto">
|
<ScrollArea class="h-72">
|
||||||
<div v-if="!count" class="px-3 py-3 text-sm text-gray-500">
|
<div v-if="!count" class="px-4 py-8 text-center">
|
||||||
Ni zapadlih aktivnosti danes.
|
<p class="text-sm text-muted-foreground">Ni zapadlih aktivnosti danes.</p>
|
||||||
</div>
|
</div>
|
||||||
<ul v-else class="divide-y">
|
<div v-else class="divide-y">
|
||||||
<li
|
<div
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="px-3 py-2 text-sm flex items-start gap-2"
|
class="px-4 py-3 flex items-start gap-3 hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 space-y-1">
|
||||||
<div class="font-medium text-gray-800 truncate">
|
<div class="font-medium truncate">
|
||||||
<template v-if="item.contract?.uuid">
|
<template v-if="item.contract?.uuid">
|
||||||
Pogodba:
|
Pogodba:
|
||||||
<Link
|
<Link
|
||||||
@@ -135,7 +129,7 @@ function markRead(item) {
|
|||||||
client_case: item.contract.client_case.uuid,
|
client_case: item.contract.client_case.uuid,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="text-indigo-600 hover:text-indigo-700 hover:underline"
|
class="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{{ item.contract?.reference || "—" }}
|
{{ item.contract?.reference || "—" }}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -148,7 +142,7 @@ function markRead(item) {
|
|||||||
:href="
|
:href="
|
||||||
route('clientCase.show', { client_case: item.client_case.uuid })
|
route('clientCase.show', { client_case: item.client_case.uuid })
|
||||||
"
|
"
|
||||||
class="text-indigo-600 hover:text-indigo-700 hover:underline"
|
class="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{{ item.client_case?.person?.full_name || "—" }}
|
{{ item.client_case?.person?.full_name || "—" }}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -157,37 +151,38 @@ function markRead(item) {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
|
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
|
||||||
<div
|
<div
|
||||||
class="text-xs text-gray-500 truncate"
|
class="text-xs text-muted-foreground truncate"
|
||||||
v-if="item.contract?.client?.person?.full_name"
|
v-if="item.contract?.client?.person?.full_name"
|
||||||
>
|
>
|
||||||
Partner: {{ item.contract.client.person.full_name }}
|
Partner: {{ item.contract.client.person.full_name }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-xs text-gray-500 truncate"
|
class="text-xs text-muted-foreground truncate"
|
||||||
v-else-if="item.client_case?.client?.person?.full_name"
|
v-else-if="item.client_case?.client?.person?.full_name"
|
||||||
>
|
>
|
||||||
Partner: {{ item.client_case.client.person.full_name }}
|
Partner: {{ item.client_case.client.person.full_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600 truncate" v-if="item.contract">
|
<div class="text-sm truncate" v-if="item.contract">
|
||||||
{{ fmtEUR(item.contract?.account?.balance_amount) }}
|
{{ fmtEUR(item.contract?.account?.balance_amount) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-end gap-1">
|
<div class="flex flex-col items-end gap-1.5 shrink-0">
|
||||||
<div class="text-xs text-gray-500 whitespace-nowrap">
|
<div class="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{{ fmtDate(item.due_date) }}
|
{{ fmtDate(item.due_date) }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
class="text-[11px] text-gray-400 hover:text-gray-600"
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
@click.stop="markRead(item)"
|
@click.stop="markRead(item)"
|
||||||
title="Skrij obvestilo"
|
title="Skrij obvestilo"
|
||||||
>
|
>
|
||||||
Skrij
|
Skrij
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Dropdown>
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,810 @@
|
|||||||
|
<script setup>
|
||||||
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
|
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||||
|
import { ref, computed, nextTick } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/Components/ui/card";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import { Separator } from "@/Components/ui/separator";
|
||||||
|
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
||||||
|
import {
|
||||||
|
PackageIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
UsersIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SaveIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
FilterIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
XCircleIcon,
|
||||||
|
BadgeCheckIcon,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import { fmtDateDMY } from "@/Utilities/functions";
|
||||||
|
import { upperFirst } from "lodash";
|
||||||
|
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||||
|
import AppRangeDatePicker from "@/Components/app/ui/AppRangeDatePicker.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profiles: { type: Array, default: () => [] },
|
||||||
|
senders: { type: Array, default: () => [] },
|
||||||
|
templates: { type: Array, default: () => [] },
|
||||||
|
segments: { type: Array, default: () => [] },
|
||||||
|
clients: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const creatingFromContracts = ref(false);
|
||||||
|
|
||||||
|
const createMode = ref("numbers"); // 'numbers' | 'contracts'
|
||||||
|
const form = useForm({
|
||||||
|
type: "sms",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
profile_id: null,
|
||||||
|
sender_id: null,
|
||||||
|
template_id: null,
|
||||||
|
delivery_report: false,
|
||||||
|
body: "",
|
||||||
|
numbers: "", // one per line
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredSenders = computed(() => {
|
||||||
|
if (!form.profile_id) return props.senders;
|
||||||
|
return props.senders.filter((s) => s.profile_id === form.profile_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTemplateChange() {
|
||||||
|
const template = props.templates.find((t) => t.id === form.template_id);
|
||||||
|
if (template?.content) {
|
||||||
|
form.body = template.content;
|
||||||
|
} else {
|
||||||
|
form.body = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCreate() {
|
||||||
|
const lines = (form.numbers || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!lines.length) return;
|
||||||
|
if (!form.profile_id && !form.template_id) {
|
||||||
|
alert("Izberi SMS profil ali predlogo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.template_id && !form.body) {
|
||||||
|
alert("Vnesi vsebino sporočila ali izberi predlogo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "sms",
|
||||||
|
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
|
||||||
|
description: form.description || "",
|
||||||
|
items: lines.map((number) => ({
|
||||||
|
number,
|
||||||
|
payload: {
|
||||||
|
profile_id: form.profile_id,
|
||||||
|
sender_id: form.sender_id,
|
||||||
|
template_id: form.template_id,
|
||||||
|
delivery_report: !!form.delivery_report,
|
||||||
|
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post(route("admin.packages.store"), payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.visit(route("admin.packages.index"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contracts mode state & actions
|
||||||
|
const contracts = ref({
|
||||||
|
data: [],
|
||||||
|
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||||
|
});
|
||||||
|
const segmentId = ref(null);
|
||||||
|
const search = ref("");
|
||||||
|
const clientId = ref(null);
|
||||||
|
const startDateRange = ref({ start: null, end: null });
|
||||||
|
const promiseDateRange = ref({ start: null, end: null });
|
||||||
|
const onlyMobile = ref(false);
|
||||||
|
const onlyValidated = ref(false);
|
||||||
|
const loadingContracts = ref(false);
|
||||||
|
|
||||||
|
// Transform clients for AppCombobox
|
||||||
|
const clientItems = computed(() =>
|
||||||
|
props.clients.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const selectedContractIds = ref(new Set());
|
||||||
|
const perPage = ref(25);
|
||||||
|
|
||||||
|
// DataTable columns definition
|
||||||
|
const contractColumns = [
|
||||||
|
{ accessorKey: "reference", header: "Pogodba" },
|
||||||
|
{
|
||||||
|
id: "person",
|
||||||
|
accessorFn: (row) => row.person?.full_name || "—",
|
||||||
|
header: "Primer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
accessorFn: (row) => row.client?.name || "—",
|
||||||
|
header: "Stranka",
|
||||||
|
},
|
||||||
|
{ accessorKey: "start_date", header: "Datum začetka" },
|
||||||
|
{ accessorKey: "promise_date", header: "Zadnja obljuba" },
|
||||||
|
{
|
||||||
|
id: "selected_phone",
|
||||||
|
accessorFn: (row) => row.selected_phone?.number || "—",
|
||||||
|
header: "Izbrana številka",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "segment",
|
||||||
|
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
|
||||||
|
header: "Segment",
|
||||||
|
},
|
||||||
|
{ accessorKey: "no_phone_reason", header: "Opomba" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function onSelectionChange(selectedKeys) {
|
||||||
|
// selectedKeys are indices from the table
|
||||||
|
const newSelection = new Set();
|
||||||
|
selectedKeys.forEach((key) => {
|
||||||
|
const index = parseInt(key);
|
||||||
|
if (contracts.value.data[index]) {
|
||||||
|
newSelection.add(contracts.value.data[index].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectedContractIds.value = newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContracts(url = null) {
|
||||||
|
loadingContracts.value = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||||
|
if (search.value) params.append("q", search.value);
|
||||||
|
if (clientId.value) params.append("client_id", clientId.value);
|
||||||
|
if (startDateRange.value?.start)
|
||||||
|
params.append("start_date_from", startDateRange.value.start);
|
||||||
|
if (startDateRange.value?.end)
|
||||||
|
params.append("start_date_to", startDateRange.value.end);
|
||||||
|
if (promiseDateRange.value?.start)
|
||||||
|
params.append("promise_date_from", promiseDateRange.value.start);
|
||||||
|
if (promiseDateRange.value?.end)
|
||||||
|
params.append("promise_date_to", promiseDateRange.value.end);
|
||||||
|
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||||
|
if (onlyValidated.value) params.append("only_validated", "1");
|
||||||
|
params.append("per_page", perPage.value);
|
||||||
|
|
||||||
|
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||||
|
const { data: json } = await axios.get(target, {
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for next tick before updating to avoid Vue reconciliation issues
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
contracts.value = {
|
||||||
|
data: json.data || [],
|
||||||
|
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
loadingContracts.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectContract(id) {
|
||||||
|
const s = selectedContractIds.value;
|
||||||
|
if (s.has(id)) {
|
||||||
|
s.delete(id);
|
||||||
|
} else {
|
||||||
|
s.add(id);
|
||||||
|
}
|
||||||
|
selectedContractIds.value = new Set(Array.from(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get row selection state for DataTable
|
||||||
|
const rowSelection = computed(() => {
|
||||||
|
const selection = {};
|
||||||
|
contracts.value.data.forEach((contract, index) => {
|
||||||
|
if (selectedContractIds.value.has(contract.id)) {
|
||||||
|
selection[index.toString()] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return selection;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed key to force DataTable re-render on page change
|
||||||
|
const tableKey = computed(() => {
|
||||||
|
return `contracts-${contracts.value.meta.current_page}-${contracts.value.data.length}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedContractIds.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
if (page < 1 || page > contracts.value.meta.last_page) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||||
|
if (search.value) params.append("q", search.value);
|
||||||
|
if (clientId.value) params.append("client_id", clientId.value);
|
||||||
|
if (startDateRange.value?.start)
|
||||||
|
params.append("start_date_from", startDateRange.value.start);
|
||||||
|
if (startDateRange.value?.end) params.append("start_date_to", startDateRange.value.end);
|
||||||
|
if (promiseDateRange.value?.start)
|
||||||
|
params.append("promise_date_from", promiseDateRange.value.start);
|
||||||
|
if (promiseDateRange.value?.end)
|
||||||
|
params.append("promise_date_to", promiseDateRange.value.end);
|
||||||
|
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||||
|
if (onlyValidated.value) params.append("only_validated", "1");
|
||||||
|
params.append("per_page", perPage.value);
|
||||||
|
params.append("page", page);
|
||||||
|
|
||||||
|
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||||
|
loadContracts(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
segmentId.value = null;
|
||||||
|
clientId.value = null;
|
||||||
|
search.value = "";
|
||||||
|
startDateRange.value = { start: null, end: null };
|
||||||
|
promiseDateRange.value = { start: null, end: null };
|
||||||
|
onlyMobile.value = false;
|
||||||
|
onlyValidated.value = false;
|
||||||
|
contracts.value = {
|
||||||
|
data: [],
|
||||||
|
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCreateFromContracts() {
|
||||||
|
const ids = Array.from(selectedContractIds.value);
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
|
||||||
|
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
|
||||||
|
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
|
||||||
|
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "sms",
|
||||||
|
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
|
||||||
|
description: form.description || "",
|
||||||
|
payload: {
|
||||||
|
profile_id: form.profile_id,
|
||||||
|
sender_id: form.sender_id,
|
||||||
|
template_id: form.template_id,
|
||||||
|
delivery_report: !!form.delivery_report,
|
||||||
|
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||||
|
},
|
||||||
|
contract_ids: ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
creatingFromContracts.value = true;
|
||||||
|
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.visit(route("admin.packages.index"));
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
const first = errors && Object.values(errors)[0];
|
||||||
|
if (first) {
|
||||||
|
alert(String(first));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
creatingFromContracts.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const numbersCount = computed(() => {
|
||||||
|
return (form.numbers || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean).length;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminLayout title="Ustvari SMS paket">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<Link :href="route('admin.packages.index')">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||||
|
Nazaj
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<PackageIcon class="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Ustvari SMS paket</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">Pošlji SMS sporočila v paketu</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<Tabs v-model="createMode" class="w-full">
|
||||||
|
<TabsList class="flex flex-row justify-baseline py-4">
|
||||||
|
<TabsTrigger value="numbers" class="p-3">
|
||||||
|
<span class="flex gap-2 items-center align-middle justify-center">
|
||||||
|
<PhoneIcon class="h-5 w-5" />Vnos številk
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="contracts" class="p-3">
|
||||||
|
<span class="flex gap-2 items-center align-middle justify-center">
|
||||||
|
<UsersIcon class="h-5 w-5" />Iz pogodb (segment)
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<!-- Package Details Card -->
|
||||||
|
<Card class="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Podatki o paketu</CardTitle>
|
||||||
|
<CardDescription>Osnovne informacije in SMS nastavitve</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-6">
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">Ime paketa</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
placeholder="Npr. SMS kampanja december 2024"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">Opis</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
placeholder="Neobvezen opis paketa"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- SMS Configuration -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-4">SMS nastavitve</h3>
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>SMS profil</Label>
|
||||||
|
<Select v-model="form.profile_id">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Izberi profil" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">—</SelectItem>
|
||||||
|
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">
|
||||||
|
{{ p.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Pošiljatelj</Label>
|
||||||
|
<Select v-model="form.sender_id">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Izberi pošiljatelja" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">—</SelectItem>
|
||||||
|
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
|
||||||
|
{{ s.sname }}
|
||||||
|
<span v-if="s.phone_number" class="text-muted-foreground">
|
||||||
|
({{ s.phone_number }})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Predloga</Label>
|
||||||
|
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Izberi predlogo" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">—</SelectItem>
|
||||||
|
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">
|
||||||
|
{{ t.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="body">Vsebina sporočila</Label>
|
||||||
|
<Textarea
|
||||||
|
id="body"
|
||||||
|
v-model="form.body"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Vsebina SMS sporočila..."
|
||||||
|
class="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="form.delivery_report"
|
||||||
|
@update:model-value="(val) => (form.delivery_report = val)"
|
||||||
|
id="delivery-report"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
<Label for="delivery-report" class="cursor-pointer text-sm">
|
||||||
|
Zahtevaj delivery report
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ form.body?.length || 0 }} znakov
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Numbers Mode -->
|
||||||
|
<TabsContent value="numbers">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Telefonske številke</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>Vnesi telefonske številke prejemnikov (ena na vrstico)</CardDescription
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
v-model="form.numbers"
|
||||||
|
rows="10"
|
||||||
|
placeholder="+38640123456 +38640123457 +38641234567"
|
||||||
|
class="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
<strong>{{ numbersCount }}</strong>
|
||||||
|
{{
|
||||||
|
numbersCount === 1
|
||||||
|
? "številka"
|
||||||
|
: numbersCount < 5
|
||||||
|
? "številke"
|
||||||
|
: "številk"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Badge v-if="numbersCount > 0" variant="secondary">
|
||||||
|
<CheckCircle2Icon class="h-3 w-3 mr-1" />
|
||||||
|
Pripravljeno
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
@click="router.visit(route('admin.packages.index'))"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Prekliči
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="submitCreate"
|
||||||
|
:disabled="numbersCount === 0 || (!form.profile_id && !form.template_id)"
|
||||||
|
>
|
||||||
|
<SaveIcon class="h-4 w-4 mr-2" />
|
||||||
|
Ustvari paket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- Contracts Mode -->
|
||||||
|
<TabsContent value="contracts">
|
||||||
|
<Card class="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Filtri za pogodbe</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>Najdi prejemnike glede na pogodbe in segmente</CardDescription
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" class="text-xs">
|
||||||
|
<FilterIcon class="h-3 w-3 mr-1" />
|
||||||
|
Napredno iskanje
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-6">
|
||||||
|
<!-- Basic filters -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Segment</Label>
|
||||||
|
<Select v-model="segmentId" @update:model-value="loadContracts()">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Vsi segmenti" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||||
|
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||||
|
{{ s.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Stranka</Label>
|
||||||
|
<AppCombobox
|
||||||
|
v-model="clientId"
|
||||||
|
:items="clientItems"
|
||||||
|
placeholder="Vse stranke"
|
||||||
|
search-placeholder="Išči stranko..."
|
||||||
|
empty-text="Stranka ni najdena."
|
||||||
|
button-class="w-full"
|
||||||
|
@update:model-value="loadContracts()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Iskanje po referenci</Label>
|
||||||
|
<Input
|
||||||
|
v-model="search"
|
||||||
|
@keyup.enter="loadContracts()"
|
||||||
|
placeholder="Vnesi referenco..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Date filters -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<CalendarIcon class="h-4 w-4" />
|
||||||
|
Datumski filtri
|
||||||
|
</h4>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
|
||||||
|
<AppRangeDatePicker
|
||||||
|
v-model="startDateRange"
|
||||||
|
placeholder="Izberi obdobje"
|
||||||
|
button-class="w-full"
|
||||||
|
:number-of-months="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
|
||||||
|
<AppRangeDatePicker
|
||||||
|
v-model="promiseDateRange"
|
||||||
|
placeholder="Izberi obdobje"
|
||||||
|
button-class="w-full"
|
||||||
|
:number-of-months="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Phone filters -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="onlyMobile"
|
||||||
|
@update:model-value="
|
||||||
|
(val) => {
|
||||||
|
onlyMobile = val;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
id="only-mobile"
|
||||||
|
/>
|
||||||
|
<Label for="only-mobile" class="cursor-pointer text-sm">
|
||||||
|
Samo mobilne številke
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="onlyValidated"
|
||||||
|
@update:model-value="
|
||||||
|
(val) => {
|
||||||
|
onlyValidated = val;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
id="only-validated"
|
||||||
|
/>
|
||||||
|
<Label for="only-validated" class="cursor-pointer text-sm">
|
||||||
|
Samo potrjene številke
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button @click="loadContracts()">
|
||||||
|
<SearchIcon class="h-4 w-4" />
|
||||||
|
Išči pogodbe
|
||||||
|
</Button>
|
||||||
|
<Button @click="resetFilters" variant="outline">
|
||||||
|
<XCircleIcon class="h-4 w-4" />
|
||||||
|
Počisti filtre
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<Card v-if="contracts.data.length > 0 || loadingContracts">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
|
||||||
|
<CardDescription v-if="contracts.meta.total > 0">
|
||||||
|
Najdeno {{ contracts.meta.total }}
|
||||||
|
{{
|
||||||
|
contracts.meta.total === 1
|
||||||
|
? "pogodba"
|
||||||
|
: contracts.meta.total < 5
|
||||||
|
? "pogodbe"
|
||||||
|
: "pogodb"
|
||||||
|
}}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Button -->
|
||||||
|
<div class="flex justify-end gap-2" v-if="selectedContractIds.size > 0">
|
||||||
|
<Badge
|
||||||
|
v-if="selectedContractIds.size > 0"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle2Icon class="h-3 w-3" />
|
||||||
|
Izbrano: {{ selectedContractIds.size }}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
@click="router.visit(route('admin.packages.index'))"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Prekliči
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="submitCreateFromContracts"
|
||||||
|
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
||||||
|
>
|
||||||
|
<SaveIcon class="h-4 w-4" />
|
||||||
|
Ustvari paket ({{ selectedContractIds.size }}
|
||||||
|
{{
|
||||||
|
selectedContractIds.size === 1
|
||||||
|
? "pogodba"
|
||||||
|
: selectedContractIds.size < 5
|
||||||
|
? "pogodbe"
|
||||||
|
: "pogodb"
|
||||||
|
}})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0">
|
||||||
|
<DataTableNew2
|
||||||
|
v-if="!loadingContracts"
|
||||||
|
:key="tableKey"
|
||||||
|
:columns="contractColumns"
|
||||||
|
:data="contracts.data"
|
||||||
|
:enableRowSelection="true"
|
||||||
|
:rowSelection="rowSelection"
|
||||||
|
:showPagination="true"
|
||||||
|
:page-size="50"
|
||||||
|
:page-size-options="[10, 15, 25, 50, 100]"
|
||||||
|
:showToolbar="false"
|
||||||
|
@selection:change="onSelectionChange"
|
||||||
|
>
|
||||||
|
<template #cell-reference="{ row }">
|
||||||
|
<div v-if="row.original" class="space-y-1">
|
||||||
|
<p class="font-medium">{{ row.original.reference || "—" }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground font-mono">
|
||||||
|
#{{ row.original.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-person="{ row }">
|
||||||
|
<span v-if="row.original" class="text-xs">{{
|
||||||
|
row.original.person?.full_name || "—"
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-client="{ row }">
|
||||||
|
<span v-if="row.original" class="text-xs">{{
|
||||||
|
row.original.client?.name || "—"
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-start_date="{ row }">
|
||||||
|
{{ fmtDateDMY(row.start_date) || "—" }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-promise_date="{ row }">
|
||||||
|
{{ fmtDateDMY(row.promise_date) || "—" }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-selected_phone="{ row }">
|
||||||
|
<div v-if="row.selected_phone" class="space-y-1">
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<span>{{ row.selected_phone.number }}</span>
|
||||||
|
<span
|
||||||
|
><Badge
|
||||||
|
v-if="row.selected_phone.validated"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<BadgeCheckIcon />
|
||||||
|
Potrjena
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else
|
||||||
|
variant="destructive"
|
||||||
|
class="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums text-accent"
|
||||||
|
>
|
||||||
|
Nepotrjena
|
||||||
|
</Badge></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-no_phone_reason="{ row }">
|
||||||
|
<span v-if="row.original" class="text-xs text-muted-foreground">{{
|
||||||
|
row.original.no_phone_reason || "—"
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</DataTableNew2>
|
||||||
|
|
||||||
|
<div v-else class="text-center text-muted-foreground py-24">Nalaganje...</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
@@ -1,57 +1,35 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
import { Link, router } from "@inertiajs/vue3";
|
||||||
import { ref, computed } from "vue";
|
import { ref } from "vue";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
|
||||||
import { Label } from "@/Components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/Components/ui/select";
|
|
||||||
import { Textarea } from "@/Components/ui/textarea";
|
|
||||||
import { Checkbox } from "@/Components/ui/checkbox";
|
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
AlertDialog,
|
||||||
TableBody,
|
AlertDialogAction,
|
||||||
TableCell,
|
AlertDialogCancel,
|
||||||
TableHead,
|
AlertDialogContent,
|
||||||
TableHeader,
|
AlertDialogDescription,
|
||||||
TableRow,
|
AlertDialogFooter,
|
||||||
} from "@/Components/ui/table";
|
AlertDialogHeader,
|
||||||
import { Separator } from "@/Components/ui/separator";
|
AlertDialogTitle,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||||
import Pagination from "@/Components/Pagination.vue";
|
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
|
||||||
import {
|
|
||||||
PackageIcon,
|
|
||||||
PlusIcon,
|
|
||||||
XIcon,
|
|
||||||
SearchIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
EyeIcon,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||||
|
import { fmtDateTime } from "@/Utilities/functions";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
packages: { type: Object, required: true },
|
packages: { type: Object, required: true },
|
||||||
profiles: { type: Array, default: () => [] },
|
|
||||||
senders: { type: Array, default: () => [] },
|
|
||||||
templates: { type: Array, default: () => [] },
|
|
||||||
segments: { type: Array, default: () => [] },
|
|
||||||
clients: { type: Array, default: () => [] },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deletingId = ref(null);
|
const deletingId = ref(null);
|
||||||
const creatingFromContracts = ref(false);
|
const packageToDelete = ref(null);
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ accessorKey: "id", header: "ID" },
|
{ accessorKey: "id", header: "ID" },
|
||||||
{ accessorKey: "uuid", header: "UUID" },
|
|
||||||
{ accessorKey: "name", header: "Ime" },
|
{ accessorKey: "name", header: "Ime" },
|
||||||
{ accessorKey: "type", header: "Tip" },
|
{ accessorKey: "type", header: "Tip" },
|
||||||
{ accessorKey: "status", header: "Status" },
|
{ accessorKey: "status", header: "Status" },
|
||||||
@@ -73,242 +51,23 @@ function goShow(id) {
|
|||||||
router.visit(route("admin.packages.show", id));
|
router.visit(route("admin.packages.show", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCreate = ref(false);
|
function openDeleteDialog(pkg) {
|
||||||
const createMode = ref("numbers"); // 'numbers' | 'contracts'
|
|
||||||
const form = useForm({
|
|
||||||
type: "sms",
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
profile_id: null,
|
|
||||||
sender_id: null,
|
|
||||||
template_id: null,
|
|
||||||
delivery_report: false,
|
|
||||||
body: "",
|
|
||||||
numbers: "", // one per line
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredSenders = computed(() => {
|
|
||||||
if (!form.profile_id) return props.senders;
|
|
||||||
return props.senders.filter((s) => s.profile_id === form.profile_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
function onTemplateChange() {
|
|
||||||
const template = props.templates.find((t) => t.id === form.template_id);
|
|
||||||
if (template?.content) {
|
|
||||||
form.body = template.content;
|
|
||||||
} else {
|
|
||||||
form.body = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitCreate() {
|
|
||||||
const lines = (form.numbers || "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (!lines.length) return;
|
|
||||||
if (!form.profile_id && !form.template_id) {
|
|
||||||
// require profile if no template/default profile resolution available
|
|
||||||
alert("Izberi SMS profil ali predlogo.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.template_id && !form.body) {
|
|
||||||
alert("Vnesi vsebino sporočila ali izberi predlogo.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "sms",
|
|
||||||
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
|
|
||||||
description: form.description || "",
|
|
||||||
items: lines.map((number) => ({
|
|
||||||
number,
|
|
||||||
payload: {
|
|
||||||
profile_id: form.profile_id,
|
|
||||||
sender_id: form.sender_id,
|
|
||||||
template_id: form.template_id,
|
|
||||||
delivery_report: !!form.delivery_report,
|
|
||||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
router.post(route("admin.packages.store"), payload, {
|
|
||||||
onSuccess: () => {
|
|
||||||
form.reset();
|
|
||||||
showCreate.value = false;
|
|
||||||
router.reload({ only: ["packages"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contracts mode state & actions
|
|
||||||
const contracts = ref({
|
|
||||||
data: [],
|
|
||||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
|
||||||
});
|
|
||||||
const segmentId = ref(null);
|
|
||||||
const search = ref("");
|
|
||||||
const clientId = ref(null);
|
|
||||||
const startDateFrom = ref("");
|
|
||||||
const startDateTo = ref("");
|
|
||||||
const promiseDateFrom = ref("");
|
|
||||||
const promiseDateTo = ref("");
|
|
||||||
const onlyMobile = ref(false);
|
|
||||||
const onlyValidated = ref(false);
|
|
||||||
const loadingContracts = ref(false);
|
|
||||||
const selectedContractIds = ref(new Set());
|
|
||||||
const perPage = ref(25);
|
|
||||||
|
|
||||||
async function loadContracts(url = null) {
|
|
||||||
loadingContracts.value = true;
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
|
||||||
if (search.value) params.append("q", search.value);
|
|
||||||
if (clientId.value) params.append("client_id", clientId.value);
|
|
||||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
|
||||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
|
||||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
|
||||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
|
||||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
|
||||||
if (onlyValidated.value) params.append("only_validated", "1");
|
|
||||||
params.append("per_page", perPage.value);
|
|
||||||
|
|
||||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
|
||||||
const res = await fetch(target, {
|
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
contracts.value = {
|
|
||||||
data: json.data || [],
|
|
||||||
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
loadingContracts.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectContract(id) {
|
|
||||||
const s = selectedContractIds.value;
|
|
||||||
if (s.has(id)) {
|
|
||||||
s.delete(id);
|
|
||||||
} else {
|
|
||||||
s.add(id);
|
|
||||||
}
|
|
||||||
// force reactivity
|
|
||||||
selectedContractIds.value = new Set(Array.from(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
selectedContractIds.value = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePackage(pkg) {
|
|
||||||
if (!pkg || pkg.status !== "draft") return;
|
if (!pkg || pkg.status !== "draft") return;
|
||||||
if (!confirm(`Izbrišem paket #${pkg.id}?`)) return;
|
packageToDelete.value = pkg;
|
||||||
deletingId.value = pkg.id;
|
showDeleteDialog.value = true;
|
||||||
router.delete(route("admin.packages.destroy", pkg.id), {
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!packageToDelete.value) return;
|
||||||
|
deletingId.value = packageToDelete.value.id;
|
||||||
|
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.reload({ only: ["packages"] });
|
router.reload({ only: ["packages"] });
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
deletingId.value = null;
|
deletingId.value = null;
|
||||||
},
|
showDeleteDialog.value = false;
|
||||||
});
|
packageToDelete.value = null;
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll() {
|
|
||||||
const currentPageIds = contracts.value.data.map((c) => c.id);
|
|
||||||
const allSelected = currentPageIds.every((id) => selectedContractIds.value.has(id));
|
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
// Deselect all on current page
|
|
||||||
currentPageIds.forEach((id) => selectedContractIds.value.delete(id));
|
|
||||||
} else {
|
|
||||||
// Select all on current page
|
|
||||||
currentPageIds.forEach((id) => selectedContractIds.value.add(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force reactivity
|
|
||||||
selectedContractIds.value = new Set(Array.from(selectedContractIds.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
const allCurrentPageSelected = computed(() => {
|
|
||||||
if (!contracts.value.data.length) return false;
|
|
||||||
return contracts.value.data.every((c) => selectedContractIds.value.has(c.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const someCurrentPageSelected = computed(() => {
|
|
||||||
if (!contracts.value.data.length) return false;
|
|
||||||
return (
|
|
||||||
contracts.value.data.some((c) => selectedContractIds.value.has(c.id)) &&
|
|
||||||
!allCurrentPageSelected.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function goContractsPage(delta) {
|
|
||||||
const { current_page } = contracts.value.meta;
|
|
||||||
const nextPage = current_page + delta;
|
|
||||||
if (nextPage < 1 || nextPage > contracts.value.meta.last_page) return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
|
||||||
if (search.value) params.append("q", search.value);
|
|
||||||
if (clientId.value) params.append("client_id", clientId.value);
|
|
||||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
|
||||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
|
||||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
|
||||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
|
||||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
|
||||||
if (onlyValidated.value) params.append("only_validated", "1");
|
|
||||||
params.append("per_page", perPage.value);
|
|
||||||
params.append("page", nextPage);
|
|
||||||
|
|
||||||
const base = `${route("admin.packages.contracts")}?${params.toString()}`;
|
|
||||||
loadContracts(base);
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitCreateFromContracts() {
|
|
||||||
const ids = Array.from(selectedContractIds.value);
|
|
||||||
if (!ids.length) return;
|
|
||||||
// Optional quick client-side sanity: if all selected are from current page and none have phones, warn early.
|
|
||||||
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
|
|
||||||
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
|
|
||||||
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
|
|
||||||
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
type: "sms",
|
|
||||||
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
|
|
||||||
description: form.description || "",
|
|
||||||
payload: {
|
|
||||||
profile_id: form.profile_id,
|
|
||||||
sender_id: form.sender_id,
|
|
||||||
template_id: form.template_id,
|
|
||||||
delivery_report: !!form.delivery_report,
|
|
||||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
|
||||||
},
|
|
||||||
contract_ids: ids,
|
|
||||||
};
|
|
||||||
|
|
||||||
creatingFromContracts.value = true;
|
|
||||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
|
||||||
onSuccess: () => {
|
|
||||||
clearSelection();
|
|
||||||
showCreate.value = false;
|
|
||||||
router.reload({ only: ["packages"] });
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
// Show the first validation error if present
|
|
||||||
const first = errors && Object.values(errors)[0];
|
|
||||||
if (first) {
|
|
||||||
alert(String(first));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFinish: () => {
|
|
||||||
creatingFromContracts.value = false;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,444 +82,16 @@ function submitCreateFromContracts() {
|
|||||||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||||
<CardTitle>SMS paketi</CardTitle>
|
<CardTitle>SMS paketi</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Link :href="route('admin.packages.create')">
|
||||||
@click="showCreate = !showCreate"
|
<Button>
|
||||||
:variant="showCreate ? 'outline' : 'default'"
|
<PlusIcon class="h-4 w-4" />
|
||||||
>
|
Nov paket
|
||||||
<component :is="showCreate ? XIcon : PlusIcon" class="h-4 w-4 mr-2" />
|
|
||||||
{{ showCreate ? "Zapri" : "Nov paket" }}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-if="showCreate" class="mb-6">
|
|
||||||
<CardContent class="pt-6">
|
|
||||||
<div class="mb-4 flex items-center gap-4">
|
|
||||||
<Label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
value="numbers"
|
|
||||||
v-model="createMode"
|
|
||||||
class="rounded-full"
|
|
||||||
/>
|
|
||||||
Vnos številk
|
|
||||||
</Label>
|
|
||||||
<Label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
value="contracts"
|
|
||||||
v-model="createMode"
|
|
||||||
class="rounded-full"
|
|
||||||
/>
|
|
||||||
Iz pogodb (segment)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-3 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Profil</Label>
|
|
||||||
<Select v-model="form.profile_id">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="—" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem :value="null">—</SelectItem>
|
|
||||||
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">{{
|
|
||||||
p.name
|
|
||||||
}}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Pošiljatelj</Label>
|
|
||||||
<Select v-model="form.sender_id">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="—" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem :value="null">—</SelectItem>
|
|
||||||
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
|
|
||||||
{{ s.sname }} <span v-if="s.phone_number">({{ s.phone_number }})</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Predloga</Label>
|
|
||||||
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="—" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem :value="null">—</SelectItem>
|
|
||||||
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">{{
|
|
||||||
t.name
|
|
||||||
}}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-3 space-y-2">
|
|
||||||
<Label>Vsebina (če ni predloge)</Label>
|
|
||||||
<Textarea v-model="form.body" rows="3" placeholder="Sporočilo..." />
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
:checked="form.delivery_report"
|
|
||||||
@update:checked="(val) => (form.delivery_report = val)"
|
|
||||||
id="delivery-report"
|
|
||||||
/>
|
|
||||||
<Label for="delivery-report" class="cursor-pointer"
|
|
||||||
>Zahtevaj delivery report</Label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Numbers mode -->
|
|
||||||
<template v-if="createMode === 'numbers'">
|
|
||||||
<div class="sm:col-span-3 space-y-2">
|
|
||||||
<Label>Telefonske številke (ena na vrstico)</Label>
|
|
||||||
<Textarea
|
|
||||||
v-model="form.numbers"
|
|
||||||
rows="4"
|
|
||||||
placeholder="+38640123456 +38640123457"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-3 flex items-center justify-end gap-2">
|
|
||||||
<Button @click="submitCreate"> Ustvari paket </Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Contracts mode -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="sm:col-span-3 space-y-4">
|
|
||||||
<Separator />
|
|
||||||
<!-- Basic filters -->
|
|
||||||
<div class="grid sm:grid-cols-3 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Segment</Label>
|
|
||||||
<Select v-model="segmentId" @update:model-value="loadContracts()">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Vsi segmenti" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
|
||||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">{{
|
|
||||||
s.name
|
|
||||||
}}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Stranka</Label>
|
|
||||||
<Select v-model="clientId" @update:model-value="loadContracts()">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Vse stranke" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem :value="null">Vse stranke</SelectItem>
|
|
||||||
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">{{
|
|
||||||
c.name
|
|
||||||
}}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Iskanje po referenci</Label>
|
|
||||||
<Input
|
|
||||||
v-model="search"
|
|
||||||
@keyup.enter="loadContracts()"
|
|
||||||
type="text"
|
|
||||||
placeholder="Vnesi referenco..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date range filters -->
|
|
||||||
<Separator />
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold mb-3">Datumski filtri</h4>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Datum začetka pogodbe
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Od</Label>
|
|
||||||
<Input
|
|
||||||
v-model="startDateFrom"
|
|
||||||
@change="loadContracts()"
|
|
||||||
type="date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Do</Label>
|
|
||||||
<Input
|
|
||||||
v-model="startDateTo"
|
|
||||||
@change="loadContracts()"
|
|
||||||
type="date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
|
||||||
Datum obljube plačila
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Od</Label>
|
|
||||||
<Input
|
|
||||||
v-model="promiseDateFrom"
|
|
||||||
@change="loadContracts()"
|
|
||||||
type="date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>Do</Label>
|
|
||||||
<Input
|
|
||||||
v-model="promiseDateTo"
|
|
||||||
@change="loadContracts()"
|
|
||||||
type="date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phone filters -->
|
|
||||||
<Separator />
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
:checked="onlyMobile"
|
|
||||||
@update:checked="
|
|
||||||
(val) => {
|
|
||||||
onlyMobile = val;
|
|
||||||
loadContracts();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
id="only-mobile"
|
|
||||||
/>
|
|
||||||
<Label for="only-mobile" class="cursor-pointer"
|
|
||||||
>Samo mobilne številke</Label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
:checked="onlyValidated"
|
|
||||||
@update:checked="
|
|
||||||
(val) => {
|
|
||||||
onlyValidated = val;
|
|
||||||
loadContracts();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
id="only-validated"
|
|
||||||
/>
|
|
||||||
<Label for="only-validated" class="cursor-pointer"
|
|
||||||
>Samo potrjene številke</Label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button @click="loadContracts()">
|
|
||||||
<SearchIcon class="h-4 w-4 mr-2" />
|
|
||||||
Išči pogodbe
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click="
|
|
||||||
segmentId = null;
|
|
||||||
clientId = null;
|
|
||||||
search = '';
|
|
||||||
startDateFrom = '';
|
|
||||||
startDateTo = '';
|
|
||||||
promiseDateFrom = '';
|
|
||||||
promiseDateTo = '';
|
|
||||||
onlyMobile = false;
|
|
||||||
onlyValidated = false;
|
|
||||||
contracts.value = {
|
|
||||||
data: [],
|
|
||||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
|
||||||
};
|
|
||||||
"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Počisti filtre
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Results table -->
|
|
||||||
<div class="sm:col-span-3">
|
|
||||||
<Card>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="allCurrentPageSelected"
|
|
||||||
:indeterminate="someCurrentPageSelected"
|
|
||||||
@change="toggleSelectAll"
|
|
||||||
:disabled="!contracts.data.length"
|
|
||||||
class="rounded"
|
|
||||||
title="Izberi vse na tej strani"
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>Pogodba</TableHead>
|
|
||||||
<TableHead>Primer</TableHead>
|
|
||||||
<TableHead>Stranka</TableHead>
|
|
||||||
<TableHead>Datum začetka</TableHead>
|
|
||||||
<TableHead>Zadnja obljuba</TableHead>
|
|
||||||
<TableHead>Izbrana številka</TableHead>
|
|
||||||
<TableHead>Opomba</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody v-if="!loadingContracts">
|
|
||||||
<TableRow v-for="c in contracts.data" :key="c.id">
|
|
||||||
<TableCell>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedContractIds.has(c.id)"
|
|
||||||
@change="toggleSelectContract(c.id)"
|
|
||||||
class="rounded"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div class="font-mono text-xs text-muted-foreground">
|
|
||||||
{{ c.uuid }}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
v-if="c.case?.uuid"
|
|
||||||
:href="route('clientCase.show', c.case.uuid)"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{{ c.reference }}
|
|
||||||
</a>
|
|
||||||
<div v-else class="text-xs font-medium">{{ c.reference }}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-xs">
|
|
||||||
{{ c.person?.full_name || "—" }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-xs">{{ c.client?.name || "—" }}</TableCell>
|
|
||||||
<TableCell class="text-xs">{{
|
|
||||||
c.start_date
|
|
||||||
? new Date(c.start_date).toLocaleDateString("sl-SI")
|
|
||||||
: "—"
|
|
||||||
}}</TableCell>
|
|
||||||
<TableCell class="text-xs">{{
|
|
||||||
c.promise_date
|
|
||||||
? new Date(c.promise_date).toLocaleDateString("sl-SI")
|
|
||||||
: "—"
|
|
||||||
}}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div v-if="c.selected_phone" class="text-xs">
|
|
||||||
{{ c.selected_phone.number }}
|
|
||||||
<Badge
|
|
||||||
v-if="c.selected_phone.is_mobile"
|
|
||||||
variant="secondary"
|
|
||||||
class="ml-1"
|
|
||||||
>mobitel</Badge
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
v-if="c.selected_phone.is_validated"
|
|
||||||
variant="default"
|
|
||||||
class="ml-1"
|
|
||||||
>potrjen</Badge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-xs text-muted-foreground">—</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-xs text-muted-foreground">
|
|
||||||
{{ c.no_phone_reason || "—" }}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow v-if="!contracts.data?.length">
|
|
||||||
<TableCell colspan="8" class="text-center text-muted-foreground h-24">
|
|
||||||
Ni rezultatov. Kliknite "Išči pogodbe" za prikaz.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
<TableBody v-else>
|
|
||||||
<TableRow
|
|
||||||
><TableCell colspan="8" class="text-center text-muted-foreground h-24"
|
|
||||||
>Nalaganje...</TableCell
|
|
||||||
></TableRow
|
|
||||||
>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
<div class="mt-3 flex items-center justify-between">
|
|
||||||
<div class="text-sm text-muted-foreground flex items-center gap-4">
|
|
||||||
<span v-if="contracts.data.length">
|
|
||||||
Prikazano stran {{ contracts.meta.current_page }} od
|
|
||||||
{{ contracts.meta.last_page }} (skupaj {{ contracts.meta.total }})
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Label class="text-xs">Na stran:</Label>
|
|
||||||
<Select v-model="perPage" @update:model-value="loadContracts()">
|
|
||||||
<SelectTrigger class="w-20 h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem :value="10">10</SelectItem>
|
|
||||||
<SelectItem :value="25">25</SelectItem>
|
|
||||||
<SelectItem :value="50">50</SelectItem>
|
|
||||||
<SelectItem :value="100">100</SelectItem>
|
|
||||||
<SelectItem :value="200">200</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
@click="goContractsPage(-1)"
|
|
||||||
:disabled="contracts.meta.current_page <= 1"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>Nazaj</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
@click="goContractsPage(1)"
|
|
||||||
:disabled="contracts.meta.current_page >= contracts.meta.last_page"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>Naprej</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator class="sm:col-span-3" />
|
|
||||||
<div class="sm:col-span-3 flex items-center justify-between gap-2">
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="font-medium">Izbrano: {{ selectedContractIds.size }}</span>
|
|
||||||
<span v-if="selectedContractIds.size > 0" class="ml-2 text-muted-foreground"
|
|
||||||
>({{
|
|
||||||
selectedContractIds.size === 1
|
|
||||||
? "1 pogodba"
|
|
||||||
: `${selectedContractIds.size} pogodb`
|
|
||||||
}})</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
@click="submitCreateFromContracts"
|
|
||||||
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
|
||||||
>Ustvari paket</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AppCard
|
<AppCard
|
||||||
title=""
|
title=""
|
||||||
padding="none"
|
padding="none"
|
||||||
@@ -771,7 +102,7 @@ function submitCreateFromContracts() {
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<PackageIcon size="18" />
|
<PackageIcon size="18" />
|
||||||
<CardTitle class="uppercase">Uvozi</CardTitle>
|
<CardTitle class="uppercase">Paketi</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<DataTableNew2
|
<DataTableNew2
|
||||||
@@ -780,10 +111,6 @@ function submitCreateFromContracts() {
|
|||||||
:meta="packages"
|
:meta="packages"
|
||||||
route-name="admin.packages.index"
|
route-name="admin.packages.index"
|
||||||
>
|
>
|
||||||
<template #cell-uuid="{ row }">
|
|
||||||
<span class="font-mono text-xs text-muted-foreground">{{ row.uuid }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -797,7 +124,9 @@ function submitCreateFromContracts() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-finished_at="{ row }">
|
<template #cell-finished_at="{ row }">
|
||||||
<span class="text-xs text-muted-foreground">{{ row.finished_at ?? "—" }}</span>
|
<span class="text-xs text-muted-foreground">{{
|
||||||
|
fmtDateTime(row.finished_at) ?? "—"
|
||||||
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
@@ -807,7 +136,7 @@ function submitCreateFromContracts() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="row.status === 'draft'"
|
v-if="row.status === 'draft'"
|
||||||
@click="deletePackage(row)"
|
@click="openDeleteDialog(row)"
|
||||||
:disabled="deletingId === row.id"
|
:disabled="deletingId === row.id"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -818,5 +147,30 @@ function submitCreateFromContracts() {
|
|||||||
</template>
|
</template>
|
||||||
</DataTableNew2>
|
</DataTableNew2>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<AlertDialog v-model:open="showDeleteDialog">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Ali ste prepričani, da želite izbrisati paket
|
||||||
|
<strong v-if="packageToDelete"
|
||||||
|
>#{{ packageToDelete.id }} -
|
||||||
|
{{ packageToDelete.name || "Brez imena" }}</strong
|
||||||
|
>? Tega dejanja ni mogoče razveljaviti.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Prekliči</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
@click="confirmDelete"
|
||||||
|
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Izbriši
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
import { Switch } from "@/Components/ui/switch";
|
import { Switch } from "@/Components/ui/switch";
|
||||||
|
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -53,7 +54,9 @@ const form = useInertiaForm({
|
|||||||
props.actions[0].decisions.length > 0
|
props.actions[0].decisions.length > 0
|
||||||
? props.actions[0].decisions[0].id
|
? props.actions[0].decisions[0].id
|
||||||
: null,
|
: null,
|
||||||
contract_uuid: props.contractUuid,
|
contract_uuids: props.contractUuid ? [props.contractUuid] : (props.contracts && Array.isArray(props.contracts)
|
||||||
|
? props.contracts.map((c) => c.uuid)
|
||||||
|
: []),
|
||||||
send_auto_mail: true,
|
send_auto_mail: true,
|
||||||
attach_documents: false,
|
attach_documents: false,
|
||||||
attachment_document_ids: [],
|
attachment_document_ids: [],
|
||||||
@@ -95,7 +98,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => props.contractUuid,
|
() => props.contractUuid,
|
||||||
(cu) => {
|
(cu) => {
|
||||||
form.contract_uuid = cu || null;
|
form.contract_uuids = cu ? [cu] : [];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -103,7 +106,9 @@ watch(
|
|||||||
() => props.show,
|
() => props.show,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
form.contract_uuid = props.contractUuid || null;
|
form.contract_uuids = props.contractUuid ? [props.contractUuid] : (props.contracts && Array.isArray(props.contracts)
|
||||||
|
? props.contracts.map((c) => c.uuid)
|
||||||
|
: []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -119,20 +124,29 @@ const store = async () => {
|
|||||||
return `${y}-${m}-${day}`;
|
return `${y}-${m}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contractUuids =
|
||||||
|
Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
|
||||||
|
? form.contract_uuids
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
||||||
|
|
||||||
form
|
form
|
||||||
.transform((data) => ({
|
.transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
phone_view: props.phoneMode,
|
phone_view: props.phoneMode,
|
||||||
due_date: formatDateForSubmit(data.due_date),
|
due_date: formatDateForSubmit(data.due_date),
|
||||||
|
contract_uuids: contractUuids,
|
||||||
|
create_for_all_contracts: isMultipleContracts,
|
||||||
attachment_document_ids:
|
attachment_document_ids:
|
||||||
templateAllowsAttachments.value && data.attach_documents
|
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||||
? data.attachment_document_ids
|
? data.attachment_document_ids
|
||||||
: [],
|
: [],
|
||||||
}))
|
}))
|
||||||
.post(route("clientCase.activity.store", props.client_case), {
|
.post(route("clientCase.activity.store", props.client_case), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
close();
|
close();
|
||||||
form.reset("due_date", "amount", "note");
|
form.reset("due_date", "amount", "note", "contract_uuids");
|
||||||
emit("saved");
|
emit("saved");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -165,13 +179,45 @@ const autoMailRequiresContract = computed(() => {
|
|||||||
return types.includes("contract");
|
return types.includes("contract");
|
||||||
});
|
});
|
||||||
|
|
||||||
const autoMailDisabled = computed(() => {
|
const contractItems = computed(() => {
|
||||||
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
return pageContracts.value.map((c) => ({
|
||||||
|
value: c.uuid,
|
||||||
|
label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const autoMailDisabled = computed(() => {
|
||||||
|
if (!showSendAutoMail()) return false;
|
||||||
|
|
||||||
|
// Disable if multiple contracts selected
|
||||||
|
if (form.contract_uuids && form.contract_uuids.length > 1) return true;
|
||||||
|
|
||||||
|
// Disable if template requires contract but none selected
|
||||||
|
if (
|
||||||
|
autoMailRequiresContract.value &&
|
||||||
|
(!form.contract_uuids || form.contract_uuids.length === 0)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
const autoMailDisabledHint = computed(() => {
|
const autoMailDisabledHint = computed(() => {
|
||||||
return autoMailDisabled.value
|
if (!showSendAutoMail()) return "";
|
||||||
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
|
|
||||||
: "";
|
if (form.contract_uuids && form.contract_uuids.length > 1) {
|
||||||
|
return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
autoMailRequiresContract.value &&
|
||||||
|
(!form.contract_uuids || form.contract_uuids.length === 0)
|
||||||
|
) {
|
||||||
|
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
});
|
});
|
||||||
watch(
|
watch(
|
||||||
() => autoMailDisabled.value,
|
() => autoMailDisabled.value,
|
||||||
@@ -231,9 +277,12 @@ const docsSource = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const availableContractDocs = computed(() => {
|
const availableContractDocs = computed(() => {
|
||||||
if (!form.contract_uuid) return [];
|
if (!form.contract_uuids || form.contract_uuids.length === 0) return [];
|
||||||
|
// Only show docs if exactly one contract is selected
|
||||||
|
if (form.contract_uuids.length > 1) return [];
|
||||||
|
const selectedUuid = form.contract_uuids[0];
|
||||||
const docs = docsSource.value;
|
const docs = docsSource.value;
|
||||||
const all = docs.filter((d) => d.contract_uuid === form.contract_uuid);
|
const all = docs.filter((d) => d.contract_uuid === selectedUuid);
|
||||||
if (!props.phoneMode) return all;
|
if (!props.phoneMode) return all;
|
||||||
return all.filter((d) => {
|
return all.filter((d) => {
|
||||||
const mime = (d.mime_type || "").toLowerCase();
|
const mime = (d.mime_type || "").toLowerCase();
|
||||||
@@ -264,14 +313,14 @@ watch(
|
|||||||
[
|
[
|
||||||
() => props.phoneMode,
|
() => props.phoneMode,
|
||||||
() => templateAllowsAttachments.value,
|
() => templateAllowsAttachments.value,
|
||||||
() => form.contract_uuid,
|
() => form.contract_uuids,
|
||||||
() => form.decision_id,
|
() => form.decision_id,
|
||||||
() => availableContractDocs.value.length,
|
() => availableContractDocs.value.length,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
if (!props.phoneMode) return;
|
if (!props.phoneMode) return;
|
||||||
if (!templateAllowsAttachments.value) return;
|
if (!templateAllowsAttachments.value) return;
|
||||||
if (!form.contract_uuid) return;
|
if (!form.contract_uuids || form.contract_uuids.length !== 1) return;
|
||||||
const docs = availableContractDocs.value;
|
const docs = availableContractDocs.value;
|
||||||
if (docs.length === 0) return;
|
if (docs.length === 0) return;
|
||||||
form.attach_documents = true;
|
form.attach_documents = true;
|
||||||
@@ -324,12 +373,32 @@ watch(
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Pogodbe</Label>
|
||||||
|
<AppMultiSelect
|
||||||
|
v-model="form.contract_uuids"
|
||||||
|
:items="contractItems"
|
||||||
|
placeholder="Izberi pogodbe (neobvezno)"
|
||||||
|
search-placeholder="Išči pogodbo..."
|
||||||
|
empty-text="Ni pogodb."
|
||||||
|
:clearable="true"
|
||||||
|
:show-selected-chips="true"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="form.contract_uuids && form.contract_uuids.length > 1"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako
|
||||||
|
pogodbo).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="activityNote">Opomba</Label>
|
<Label for="activityNote">Opomba</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="activityNote"
|
id="activityNote"
|
||||||
v-model="form.note"
|
v-model="form.note"
|
||||||
class="block w-full"
|
class="block w-full max-h-72"
|
||||||
placeholder="Opomba"
|
placeholder="Opomba"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,10 +427,7 @@ watch(
|
|||||||
<div v-if="showSendAutoMail()" class="space-y-2">
|
<div v-if="showSendAutoMail()" class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
|
||||||
v-model="form.send_auto_mail"
|
|
||||||
:disabled="autoMailDisabled"
|
|
||||||
/>
|
|
||||||
<Label class="cursor-pointer">Send auto email</Label>
|
<Label class="cursor-pointer">Send auto email</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +435,14 @@ watch(
|
|||||||
{{ autoMailDisabledHint }}
|
{{ autoMailDisabledHint }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
|
<div
|
||||||
|
v-if="
|
||||||
|
templateAllowsAttachments &&
|
||||||
|
form.contract_uuids &&
|
||||||
|
form.contract_uuids.length === 1
|
||||||
|
"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
<label class="inline-flex items-center gap-2">
|
<label class="inline-flex items-center gap-2">
|
||||||
<Switch v-model="form.attach_documents" />
|
<Switch v-model="form.attach_documents" />
|
||||||
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
||||||
@@ -383,7 +456,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
||||||
<div v-if="c.uuid === form.contract_uuid">
|
<div v-if="c.uuid === form.contract_uuids[0]">
|
||||||
<div class="font-medium text-sm text-gray-700 mb-1">
|
<div class="font-medium text-sm text-gray-700 mb-1">
|
||||||
Pogodba {{ c.reference }}
|
Pogodba {{ c.reference }}
|
||||||
</div>
|
</div>
|
||||||
@@ -391,21 +464,28 @@ watch(
|
|||||||
<div
|
<div
|
||||||
v-for="doc in availableContractDocs"
|
v-for="doc in availableContractDocs"
|
||||||
:key="doc.uuid || doc.id"
|
:key="doc.uuid || doc.id"
|
||||||
class="flex items-center gap-2 text-sm"
|
class="flex items-center max-w-sm gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="form.attachment_document_ids.includes(doc.id)"
|
:model-value="form.attachment_document_ids.includes(doc.id)"
|
||||||
@update:model-value="(checked) => {
|
@update:model-value="
|
||||||
|
(checked) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!form.attachment_document_ids.includes(doc.id)) {
|
if (!form.attachment_document_ids.includes(doc.id)) {
|
||||||
form.attachment_document_ids.push(doc.id);
|
form.attachment_document_ids.push(doc.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
form.attachment_document_ids = form.attachment_document_ids.filter(id => id !== doc.id);
|
form.attachment_document_ids = form.attachment_document_ids.filter(
|
||||||
|
(id) => id !== doc.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}"
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<span>{{ doc.original_name || doc.name }}</span>
|
<div class="wrap-anywhere">
|
||||||
|
<p>
|
||||||
|
{{ doc.original_name || doc.name }}
|
||||||
|
</p>
|
||||||
<span class="text-xs text-gray-400"
|
<span class="text-xs text-gray-400"
|
||||||
>({{ doc.extension?.toUpperCase() || "" }},
|
>({{ doc.extension?.toUpperCase() || "" }},
|
||||||
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
|
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
|
||||||
@@ -413,6 +493,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
v-if="availableContractDocs.length === 0"
|
v-if="availableContractDocs.length === 0"
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
import DialogModal from "@/Components/DialogModal.vue";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
import { Plus, Trash2 } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
client_case: { type: Object, required: true },
|
||||||
|
contract: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
const processing = ref(false);
|
||||||
|
const metaEntries = ref([]);
|
||||||
|
|
||||||
|
// Extract meta entries from contract
|
||||||
|
function extractMetaEntries(contract) {
|
||||||
|
if (!contract?.meta) return [];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const visit = (node, keyName) => {
|
||||||
|
if (node === null || node === undefined) return;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((el) => visit(el));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof node === "object") {
|
||||||
|
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
|
||||||
|
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
|
||||||
|
if (hasValue || hasTitle) {
|
||||||
|
const title = (node.title || keyName || "").toString().trim() || keyName || "";
|
||||||
|
results.push({
|
||||||
|
title,
|
||||||
|
value: node.value ?? "",
|
||||||
|
type: node.type || "string",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(node)) {
|
||||||
|
visit(v, k);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyName) {
|
||||||
|
results.push({ title: keyName, value: node ?? "", type: "string" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(contract.meta, undefined);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize meta entries when dialog opens
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal && props.contract) {
|
||||||
|
const entries = extractMetaEntries(props.contract);
|
||||||
|
metaEntries.value =
|
||||||
|
entries.length > 0 ? entries : [{ title: "", value: "", type: "string" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function addEntry() {
|
||||||
|
metaEntries.value.push({ title: "", value: "", type: "string" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(index) {
|
||||||
|
metaEntries.value.splice(index, 1);
|
||||||
|
if (metaEntries.value.length === 0) {
|
||||||
|
metaEntries.value.push({ title: "", value: "", type: "string" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!props.contract?.uuid || processing.value) return;
|
||||||
|
|
||||||
|
// Filter out empty entries and build meta object
|
||||||
|
const validEntries = metaEntries.value.filter((e) => e.title && e.title.trim() !== "");
|
||||||
|
|
||||||
|
const meta = {};
|
||||||
|
validEntries.forEach((entry) => {
|
||||||
|
meta[entry.title] = {
|
||||||
|
title: entry.title,
|
||||||
|
value: entry.value,
|
||||||
|
type: entry.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
processing.value = true;
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
route("clientCase.contract.patchMeta", {
|
||||||
|
client_case: props.client_case.uuid,
|
||||||
|
uuid: props.contract.uuid,
|
||||||
|
}),
|
||||||
|
{ meta },
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
only: ["contracts"],
|
||||||
|
onSuccess: () => {
|
||||||
|
close();
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal :show="show" max-width="3xl" @close="close">
|
||||||
|
<template #title>
|
||||||
|
<h3 class="text-lg font-semibold leading-6 text-foreground">Uredi Meta podatke</h3>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
Posodobi meta podatke za pogodbo {{ contract?.reference }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<form id="meta-edit-form" @submit.prevent="submit" class="space-y-4">
|
||||||
|
<ScrollArea class="h-[60vh]">
|
||||||
|
<div class="space-y-3 pr-4">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in metaEntries"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-start gap-2 p-3 border rounded-lg bg-muted/20"
|
||||||
|
>
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label :for="`meta-title-${index}`">Naziv</Label>
|
||||||
|
<Input
|
||||||
|
:id="`meta-title-${index}`"
|
||||||
|
v-model="entry.title"
|
||||||
|
placeholder="Vnesi naziv..."
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label :for="`meta-type-${index}`">Tip</Label>
|
||||||
|
<Select v-model="entry.type">
|
||||||
|
<SelectTrigger :id="`meta-type-${index}`" class="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="string">Tekst</SelectItem>
|
||||||
|
<SelectItem value="number">Številka</SelectItem>
|
||||||
|
<SelectItem value="date">Datum</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label :for="`meta-value-${index}`">Vrednost</Label>
|
||||||
|
<Input
|
||||||
|
:id="`meta-value-${index}`"
|
||||||
|
v-model="entry.value"
|
||||||
|
:type="
|
||||||
|
entry.type === 'date'
|
||||||
|
? 'date'
|
||||||
|
: entry.type === 'number'
|
||||||
|
? 'number'
|
||||||
|
: 'text'
|
||||||
|
"
|
||||||
|
:step="entry.type === 'number' ? '0.01' : undefined"
|
||||||
|
placeholder="Vnesi vrednost..."
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
@click="removeEntry(index)"
|
||||||
|
:disabled="metaEntries.length === 1"
|
||||||
|
class="mt-6"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" @click="addEntry" class="w-full">
|
||||||
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
|
Dodaj vnos
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<Button type="button" variant="ghost" @click="close" :disabled="processing">
|
||||||
|
Prekliči
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="meta-edit-form" :disabled="processing">
|
||||||
|
{{ processing ? "Shranjujem..." : "Shrani" }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
@@ -15,6 +15,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
|||||||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||||
import PaymentDialog from "./PaymentDialog.vue";
|
import PaymentDialog from "./PaymentDialog.vue";
|
||||||
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
||||||
|
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
|
||||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||||
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
@@ -33,6 +34,16 @@ import {
|
|||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import EmptyState from "@/Components/EmptyState.vue";
|
import EmptyState from "@/Components/EmptyState.vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
client: { type: Object, default: null },
|
client: { type: Object, default: null },
|
||||||
@@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
|
|||||||
selectedContract.value = null;
|
selectedContract.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Meta edit dialog
|
||||||
|
const showMetaEditDialog = ref(false);
|
||||||
|
|
||||||
|
const openMetaEditDialog = (c) => {
|
||||||
|
selectedContract.value = c;
|
||||||
|
showMetaEditDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMetaEditDialog = () => {
|
||||||
|
showMetaEditDialog.value = false;
|
||||||
|
selectedContract.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
// Columns configuration
|
// Columns configuration
|
||||||
const columns = computed(() => [
|
const columns = computed(() => [
|
||||||
{ key: "reference", label: "Ref.", sortable: false, align: "center" },
|
{ key: "reference", label: "Ref.", sortable: false, align: "center" },
|
||||||
@@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
|
|||||||
<div class="text-gray-500">Ni meta podatkov.</div>
|
<div class="text-gray-500">Ni meta podatkov.</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="edit && row.active" class="border-t border-gray-200 mt-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openMetaEditDialog(row)"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="faPenToSquare"
|
||||||
|
class="h-3.5 w-3.5 text-gray-600"
|
||||||
|
/>
|
||||||
|
<span>Uredi meta podatke</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
@@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
|
|||||||
:edit="edit"
|
:edit="edit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ContractMetaEditDialog
|
||||||
|
:show="showMetaEditDialog"
|
||||||
|
:client_case="client_case"
|
||||||
|
:contract="selectedContract"
|
||||||
|
@close="closeMetaEditDialog"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Generate Document Dialog -->
|
<!-- Generate Document Dialog -->
|
||||||
<CreateDialog
|
<CreateDialog
|
||||||
:show="showGenerateDialog"
|
:show="showGenerateDialog"
|
||||||
@@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
|
|||||||
@confirm="submitGenerate"
|
@confirm="submitGenerate"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">Predloga</label>
|
<Label>Predloga</Label>
|
||||||
<select
|
<Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
|
||||||
v-model="selectedTemplateSlug"
|
<SelectTrigger>
|
||||||
@change="onTemplateChange"
|
<SelectValue placeholder="Izberi predlogo..." />
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option :value="null">Izberi predlogo...</option>
|
<SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
|
||||||
<option v-for="t in templates" :key="t.slug" :value="t.slug">
|
|
||||||
{{ t.name }} (v{{ t.version }})
|
{{ t.name }} (v{{ t.version }})
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom inputs -->
|
<!-- Custom inputs -->
|
||||||
@@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
|
|||||||
<div class="border-t border-gray-200 pt-4">
|
<div class="border-t border-gray-200 pt-4">
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
|
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="token in customTokenList" :key="token">
|
<div v-for="token in customTokenList" :key="token" class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">
|
<Label>
|
||||||
{{ token.replace(/^custom\./, "") }}
|
{{ token.replace(/^custom\./, "") }}
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Textarea
|
||||||
|
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
|
||||||
v-model="customInputs[token.replace(/^custom\./, '')]"
|
v-model="customInputs[token.replace(/^custom\./, '')]"
|
||||||
type="text"
|
rows="3"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model="customInputs[token.replace(/^custom\./, '')]"
|
||||||
|
:type="
|
||||||
|
templateCustomTypes[token.replace(/^custom\./, '')] === 'date'
|
||||||
|
? 'date'
|
||||||
|
: templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
|
||||||
|
? 'number'
|
||||||
|
: 'text'
|
||||||
|
"
|
||||||
|
:step="
|
||||||
|
templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
|
||||||
|
? '0.01'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
|
|||||||
|
|
||||||
<!-- Address overrides -->
|
<!-- Address overrides -->
|
||||||
<div class="border-t border-gray-200 pt-4 space-y-3">
|
<div class="border-t border-gray-200 pt-4 space-y-3">
|
||||||
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3>
|
<h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label>
|
<Label>Naslov stranke</Label>
|
||||||
<select
|
<Select v-model="clientAddressSource">
|
||||||
v-model="clientAddressSource"
|
<SelectTrigger>
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
<option value="client">Stranka</option>
|
<SelectContent>
|
||||||
<option value="case_person">Oseba primera</option>
|
<SelectItem value="client">Stranka</SelectItem>
|
||||||
</select>
|
<SelectItem value="case_person">Oseba primera</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label>
|
<Label>Naslov osebe</Label>
|
||||||
<select
|
<Select v-model="personAddressSource">
|
||||||
v-model="personAddressSource"
|
<SelectTrigger>
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
<option value="case_person">Oseba primera</option>
|
<SelectContent>
|
||||||
<option value="client">Stranka</option>
|
<SelectItem value="case_person">Oseba primera</SelectItem>
|
||||||
</select>
|
<SelectItem value="client">Stranka</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const onDocSaved = () => {
|
|||||||
router.reload({ only: ["documents"] });
|
router.reload({ only: ["documents"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewer = ref({ open: false, src: "", title: "" });
|
const viewer = ref({ open: false, src: "", title: "", mimeType: "", filename: "" });
|
||||||
const openViewer = (doc) => {
|
const openViewer = (doc) => {
|
||||||
const kind = classifyDocument(doc);
|
const kind = classifyDocument(doc);
|
||||||
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
|
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
|
||||||
@@ -122,7 +122,13 @@ const openViewer = (doc) => {
|
|||||||
client_case: props.client_case.uuid,
|
client_case: props.client_case.uuid,
|
||||||
document: doc.uuid,
|
document: doc.uuid,
|
||||||
});
|
});
|
||||||
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
|
viewer.value = {
|
||||||
|
open: true,
|
||||||
|
src: url,
|
||||||
|
title: doc.name || doc.original_name,
|
||||||
|
mimeType: doc.mime_type || "",
|
||||||
|
filename: doc.original_name || doc.name || "",
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const url =
|
const url =
|
||||||
isContractDoc && doc.contract_uuid
|
isContractDoc && doc.contract_uuid
|
||||||
@@ -140,6 +146,8 @@ const openViewer = (doc) => {
|
|||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
viewer.value.open = false;
|
viewer.value.open = false;
|
||||||
viewer.value.src = "";
|
viewer.value.src = "";
|
||||||
|
viewer.value.mimeType = "";
|
||||||
|
viewer.value.filename = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const clientDetails = ref(false);
|
const clientDetails = ref(false);
|
||||||
@@ -210,14 +218,6 @@ const closeDrawer = () => {
|
|||||||
drawerAddActivity.value = false;
|
drawerAddActivity.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showClientDetails = () => {
|
|
||||||
clientDetails.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideClietnDetails = () => {
|
|
||||||
clientDetails.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attach segment to case
|
// Attach segment to case
|
||||||
const showAttachSegment = ref(false);
|
const showAttachSegment = ref(false);
|
||||||
const openAttachSegment = () => {
|
const openAttachSegment = () => {
|
||||||
@@ -490,6 +490,8 @@ const submitAttachSegment = () => {
|
|||||||
:show="viewer.open"
|
:show="viewer.open"
|
||||||
:src="viewer.src"
|
:src="viewer.src"
|
||||||
:title="viewer.title"
|
:title="viewer.title"
|
||||||
|
:mime-type="viewer.mimeType"
|
||||||
|
:filename="viewer.filename"
|
||||||
@close="closeViewer"
|
@close="closeViewer"
|
||||||
/>
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||||
|
import axios from "axios";
|
||||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||||
|
import DialogModal from "@/Components/DialogModal.vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Switch } from "@/Components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -19,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import { ButtonGroup } from "@/Components/ui/button-group";
|
import { ButtonGroup } from "@/Components/ui/button-group";
|
||||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||||
import { Filter, LinkIcon } from "lucide-vue-next";
|
import { Filter, LinkIcon, FileDown, LayoutIcon } from "lucide-vue-next";
|
||||||
import { Card } from "@/Components/ui/card";
|
import { Card } from "@/Components/ui/card";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { hasPermission } from "@/Services/permissions";
|
import { hasPermission } from "@/Services/permissions";
|
||||||
import InputLabel from "@/Components/InputLabel.vue";
|
import InputLabel from "@/Components/InputLabel.vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/Components/ui/dropdown-menu";
|
||||||
|
import { toNumber } from "lodash";
|
||||||
|
import { FormControl, FormField, FormFieldArray, FormLabel } from "@/Components/ui/form";
|
||||||
|
import { Field, FieldLabel } from "@/Components/ui/field";
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import FormChangeSegment from "./Partials/FormChangeSegment.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
client: Object,
|
client: Object,
|
||||||
contracts: Object,
|
contracts: Object,
|
||||||
filters: Object,
|
filters: Object,
|
||||||
segments: Object,
|
segments: Array,
|
||||||
types: Object,
|
types: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +72,48 @@ const selectedSegments = ref(
|
|||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
const filterPopoverOpen = ref(false);
|
const filterPopoverOpen = ref(false);
|
||||||
|
const selectedContracts = ref([]);
|
||||||
|
const changeSegmentDialogOpen = ref(false);
|
||||||
|
const contractTable = ref(null);
|
||||||
|
|
||||||
|
const exportDialogOpen = ref(false);
|
||||||
|
const exportScope = ref("current");
|
||||||
|
const exportColumns = ref([
|
||||||
|
"reference",
|
||||||
|
"customer",
|
||||||
|
"address",
|
||||||
|
"start",
|
||||||
|
"segment",
|
||||||
|
"balance",
|
||||||
|
]);
|
||||||
|
const exportError = ref("");
|
||||||
|
const isExporting = ref(false);
|
||||||
|
|
||||||
|
const exportableColumns = [
|
||||||
|
{ key: "reference", label: "Referenca" },
|
||||||
|
{ key: "customer", label: "Stranka" },
|
||||||
|
{ key: "address", label: "Naslov" },
|
||||||
|
{ key: "start", label: "Začetek" },
|
||||||
|
{ key: "segment", label: "Segment" },
|
||||||
|
{ key: "balance", label: "Stanje" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
|
||||||
|
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
|
||||||
|
const totalContracts = computed(() => props.contracts?.total ?? 0);
|
||||||
|
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
|
||||||
|
const allColumnsSelected = computed(
|
||||||
|
() => exportColumns.value.length === exportableColumns.length
|
||||||
|
);
|
||||||
|
const exportDisabled = computed(
|
||||||
|
() => exportColumns.value.length === 0 || isExporting.value
|
||||||
|
);
|
||||||
|
const segmentSelectItems = computed(() =>
|
||||||
|
props.segments.map((val, i) => ({
|
||||||
|
label: val.name,
|
||||||
|
value: val.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
function applyDateFilter() {
|
function applyDateFilter() {
|
||||||
filterPopoverOpen.value = false;
|
filterPopoverOpen.value = false;
|
||||||
@@ -124,6 +184,157 @@ function formatDate(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAllColumns(checked) {
|
||||||
|
exportColumns.value = checked ? exportableColumns.map((col) => col.key) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnToggle(key, checked) {
|
||||||
|
if (checked) {
|
||||||
|
if (!exportColumns.value.includes(key)) {
|
||||||
|
exportColumns.value = [...exportColumns.value, key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exportColumns.value = exportColumns.value.filter((col) => col !== key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExportScopeFromSwitch(checked) {
|
||||||
|
exportScope.value = checked ? "all" : "current";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExportDialog() {
|
||||||
|
exportDialogOpen.value = true;
|
||||||
|
exportError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExportDialog() {
|
||||||
|
exportDialogOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitExport() {
|
||||||
|
if (exportColumns.value.length === 0) {
|
||||||
|
exportError.value = "Izberi vsaj en stolpec.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
exportError.value = "";
|
||||||
|
isExporting.value = true;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
scope: exportScope.value,
|
||||||
|
columns: [...exportColumns.value],
|
||||||
|
from: dateRange.value?.start || "",
|
||||||
|
to: dateRange.value?.end || "",
|
||||||
|
search: search.value || "",
|
||||||
|
segments: selectedSegments.value.length > 0 ? selectedSegments.value.join(",") : "",
|
||||||
|
page: contractsCurrentPage.value,
|
||||||
|
per_page: contractsPerPage.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
route("client.contracts.export", { uuid: props.client.uuid }),
|
||||||
|
payload,
|
||||||
|
{ responseType: "blob" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
const filename =
|
||||||
|
extractFilenameFromHeaders(response.headers) || buildDefaultFilename();
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
exportDialogOpen.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export error:", error);
|
||||||
|
console.error("Error response:", error.response);
|
||||||
|
|
||||||
|
let errorMessage = "Izvoz je spodletel. Poskusi znova.";
|
||||||
|
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
errorMessage = "Pot za izvoz ne obstaja. Prosim kontaktiraj administratorja.";
|
||||||
|
} else if (error.response?.status === 500) {
|
||||||
|
errorMessage = "Napaka na strežniku. Poskusi znova.";
|
||||||
|
} else if (error.response?.data) {
|
||||||
|
try {
|
||||||
|
const text = await error.response.data.text();
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
errorMessage = json.message || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not parse error response:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportError.value = errorMessage;
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "data";
|
||||||
|
}
|
||||||
|
const slug = value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
|
||||||
|
return slug || "data";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultFilename() {
|
||||||
|
const now = new Date();
|
||||||
|
const dd = String(now.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yy = String(now.getFullYear()).slice(-2);
|
||||||
|
const clientName = props.client?.person?.full_name || "stranka";
|
||||||
|
return `${dd}${mm}${yy}_${slugify(clientName)}-Pogodbe.xlsx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFilenameFromHeaders(headers) {
|
||||||
|
if (!headers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const disposition =
|
||||||
|
headers["content-disposition"] || headers["Content-Disposition"] || "";
|
||||||
|
if (!disposition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1]);
|
||||||
|
} catch (error) {
|
||||||
|
return utf8Match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
|
return asciiMatch?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(selectedKeys) {
|
||||||
|
selectedContracts.value = selectedKeys.map((val, i) => {
|
||||||
|
const num = toNumber(val);
|
||||||
|
|
||||||
|
return props.contracts.data[num].uuid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialogChangeSegment() {
|
||||||
|
changeSegmentDialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContractTableSelected() {
|
||||||
|
if (contractTable.value) {
|
||||||
|
contractTable.value.clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -193,9 +404,11 @@ function formatDate(value) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
|
ref="contractTable"
|
||||||
:columns="[
|
:columns="[
|
||||||
{ key: 'reference', label: 'Referenca', sortable: false },
|
{ key: 'reference', label: 'Referenca', sortable: false },
|
||||||
{ key: 'customer', label: 'Stranka', sortable: false },
|
{ key: 'customer', label: 'Stranka', sortable: false },
|
||||||
|
{ key: 'address', label: 'Naslov', sortable: false },
|
||||||
{ key: 'start', label: 'Začetek', sortable: false },
|
{ key: 'start', label: 'Začetek', sortable: false },
|
||||||
{ key: 'segment', label: 'Segment', sortable: false },
|
{ key: 'segment', label: 'Segment', sortable: false },
|
||||||
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
|
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
|
||||||
@@ -215,11 +428,14 @@ function formatDate(value) {
|
|||||||
row-key="uuid"
|
row-key="uuid"
|
||||||
:only-props="['contracts']"
|
:only-props="['contracts']"
|
||||||
:page-size-options="[10, 15, 25, 50, 100]"
|
:page-size-options="[10, 15, 25, 50, 100]"
|
||||||
|
:enable-row-selection="true"
|
||||||
|
@selection:change="handleSelectionChange"
|
||||||
page-param-name="contracts_page"
|
page-param-name="contracts_page"
|
||||||
per-page-param-name="contracts_per_page"
|
per-page-param-name="contracts_per_page"
|
||||||
:show-toolbar="true"
|
:show-toolbar="true"
|
||||||
>
|
>
|
||||||
<template #toolbar-filters>
|
<template #toolbar-filters="{ table }">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppPopover
|
<AppPopover
|
||||||
v-model:open="filterPopoverOpen"
|
v-model:open="filterPopoverOpen"
|
||||||
align="start"
|
align="start"
|
||||||
@@ -273,7 +489,10 @@ function formatDate(value) {
|
|||||||
<AppMultiSelect
|
<AppMultiSelect
|
||||||
v-model="selectedSegments"
|
v-model="selectedSegments"
|
||||||
:items="
|
:items="
|
||||||
segments.map((s) => ({ value: String(s.id), label: s.name }))
|
segments.map((s) => ({
|
||||||
|
value: String(s.id),
|
||||||
|
label: s.name,
|
||||||
|
}))
|
||||||
"
|
"
|
||||||
placeholder="Vsi segmenti"
|
placeholder="Vsi segmenti"
|
||||||
search-placeholder="Išči segment..."
|
search-placeholder="Išči segment..."
|
||||||
@@ -303,6 +522,42 @@ function formatDate(value) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppPopover>
|
</AppPopover>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="gap-2"
|
||||||
|
@click="openExportDialog"
|
||||||
|
>
|
||||||
|
<FileDown class="h-4 w-4" />
|
||||||
|
Izvozi v Excel
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu v-if="table.getSelectedRowModel().rows.length > 0">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button class="gap-2 px-3" variant="outline">
|
||||||
|
<Badge
|
||||||
|
class="h-5 min-w-5 rounded-full font-mono tabular-nums text-accent"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{{ table.getSelectedRowModel().rows.length }}
|
||||||
|
</Badge>
|
||||||
|
Akcija
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem @click="openDialogChangeSegment">
|
||||||
|
<LayoutIcon />
|
||||||
|
Spremeni segment
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="clearContractTableSelected"
|
||||||
|
v-if="table.getSelectedRowModel().rows.length > 0"
|
||||||
|
>
|
||||||
|
Odznači izbrane
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-reference="{ row }">
|
<template #cell-reference="{ row }">
|
||||||
<Link
|
<Link
|
||||||
@@ -315,6 +570,9 @@ function formatDate(value) {
|
|||||||
<template #cell-customer="{ row }">
|
<template #cell-customer="{ row }">
|
||||||
{{ row.client_case?.person?.full_name || "-" }}
|
{{ row.client_case?.person?.full_name || "-" }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #cell-address="{ row }">
|
||||||
|
{{ row.client_case?.person?.address?.address || "-" }}
|
||||||
|
</template>
|
||||||
<template #cell-start="{ row }">
|
<template #cell-start="{ row }">
|
||||||
{{ formatDate(row.start_date) }}
|
{{ formatDate(row.start_date) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -337,5 +595,122 @@ function formatDate(value) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Excel export dialog -->
|
||||||
|
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
|
||||||
|
<template #title>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-lg font-semibold leading-6 text-foreground">Izvoz v Excel</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Izberi stolpce in obseg podatkov za izvoz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<form id="contract-export-form" class="space-y-5" @submit.prevent="submitExport">
|
||||||
|
<div class="space-y-3 rounded-lg border bg-muted/40 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-foreground">Obseg podatkov</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Preklopi, ali izvoziš samo trenutni pogled ali vse pogodbe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-md bg-background px-3 py-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">Stran</span>
|
||||||
|
<Switch
|
||||||
|
:model-value="exportScope === 'all'"
|
||||||
|
@update:modelValue="setExportScopeFromSwitch"
|
||||||
|
aria-label="Preklopi obseg izvoza"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">Vse</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border bg-background p-3 shadow-sm">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Trenutna stran</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ currentPageCount }} zapisov
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border bg-background p-3 shadow-sm">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Vse pogodbe</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ totalContracts }} zapisov</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 rounded-lg border bg-muted/40 p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-foreground">Stolpci</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Izberi, katere stolpce želiš vključiti v izvoz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="export-columns-all"
|
||||||
|
:model-value="allColumnsSelected"
|
||||||
|
@update:modelValue="toggleAllColumns"
|
||||||
|
aria-label="Označi vse stolpce"
|
||||||
|
/>
|
||||||
|
<Label for="export-columns-all" class="text-sm text-muted-foreground">
|
||||||
|
Označi vse
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="col in exportableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
class="flex items-start gap-3 rounded-lg border bg-background px-3 py-3 text-sm shadow-sm transition hover:border-primary/40"
|
||||||
|
:for="`export-col-${col.key}`"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:id="`export-col-${col.key}`"
|
||||||
|
:model-value="exportColumns.includes(col.key)"
|
||||||
|
:value="col.key"
|
||||||
|
@update:modelValue="(checked) => handleColumnToggle(col.key, checked)"
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<p class="font-medium text-foreground">{{ col.label }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Vključi stolpec v datoteko.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<Button type="button" variant="ghost" @click="closeExportDialog">
|
||||||
|
Prekliči
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="contract-export-form"
|
||||||
|
:disabled="exportDisabled"
|
||||||
|
class="gap-2"
|
||||||
|
>
|
||||||
|
<span v-if="!isExporting">Prenesi Excel</span>
|
||||||
|
<span v-else>Pripravljam ...</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
|
||||||
|
<!-- Change segment selected contracts dialog -->
|
||||||
|
|
||||||
|
<FormChangeSegment
|
||||||
|
:show="changeSegmentDialogOpen"
|
||||||
|
@close="changeSegmentDialogOpen = false"
|
||||||
|
:segments="segmentSelectItems"
|
||||||
|
:contracts="selectedContracts"
|
||||||
|
:clear-selected-rows="clearContractTableSelected"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
|||||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||||
import { hasPermission } from "@/Services/permissions";
|
import { hasPermission } from "@/Services/permissions";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card";
|
import { CardTitle } from "@/Components/ui/card";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
|
||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,8 +25,7 @@ import {
|
|||||||
import { useForm } from "vee-validate";
|
import { useForm } from "vee-validate";
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
import { Plus, UsersRoundIcon } from "lucide-vue-next";
|
||||||
import { Mail, Plug2Icon, Plus, UsersRoundIcon } from "lucide-vue-next";
|
|
||||||
import { Separator } from "@/Components/ui/separator";
|
import { Separator } from "@/Components/ui/separator";
|
||||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||||
|
|
||||||
@@ -162,7 +159,7 @@ const fmtCurrency = (v) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout title="Clients">
|
||||||
<template #header> </template>
|
<template #header> </template>
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
@@ -201,6 +198,7 @@ const fmtCurrency = (v) => {
|
|||||||
:show-pagination="false"
|
:show-pagination="false"
|
||||||
:show-toolbar="true"
|
:show-toolbar="true"
|
||||||
:hoverable="true"
|
:hoverable="true"
|
||||||
|
:page-size="100"
|
||||||
row-key="uuid"
|
row-key="uuid"
|
||||||
:striped="true"
|
:striped="true"
|
||||||
empty-text="Ni najdenih naročnikov."
|
empty-text="Ni najdenih naročnikov."
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup>
|
||||||
|
import DialogModal from "@/Components/DialogModal.vue";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/Components/ui/field";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
|
import { useForm, Field as VeeField } from "vee-validate";
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
segments: { type: Array, default: [] },
|
||||||
|
contracts: { type: Array, default: [] },
|
||||||
|
clearSelectedRows: { type: Function, default: () => console.log("test") },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const processing = ref(false);
|
||||||
|
|
||||||
|
// vee-validate Form setup
|
||||||
|
const formSchema = toTypedSchema(
|
||||||
|
z.object({
|
||||||
|
segment_id: z
|
||||||
|
.number()
|
||||||
|
.refine((val) => props.segments.find((item) => item.value == val) !== undefined, {
|
||||||
|
message: "Izbran segment ne obstaja v zbirki segmentov",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handleSubmit, resetForm, errors } = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
|
processing.value = true;
|
||||||
|
router.patch(
|
||||||
|
route("contracts.segment"),
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
contracts: props.contracts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
router.reload({ only: ["contracts"] });
|
||||||
|
close();
|
||||||
|
resetForm();
|
||||||
|
props.clearSelectedRows();
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
errors = e;
|
||||||
|
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log(props.segments);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal :show="show" @close="close">
|
||||||
|
<template #title>
|
||||||
|
<h3 class="text-lg font-semibold leading-6 text-foreground">
|
||||||
|
Spremeni segment pogodbam
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<form id="segment-change-form" @submit.prevent="onSubmit">
|
||||||
|
<VeeField v-slot="{ field, errors }" name="segment_id">
|
||||||
|
<Field orientation="responsive" :data-invalid="!!errors.length">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldLabel for="segment">Segment</FieldLabel>
|
||||||
|
<FieldDescription>Izberi segment za preusmeritev</FieldDescription>
|
||||||
|
<FieldError v-if="errors.length" :errors="errors" />
|
||||||
|
</FieldContent>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
:model-value="field.value"
|
||||||
|
@update:model-value="field.onChange"
|
||||||
|
@blur="field.onBlur"
|
||||||
|
>
|
||||||
|
<SelectTrigger id="segment_id" :aria-invalid="!!errors.length">
|
||||||
|
<SelectValue placeholder="Izberi segment..."></SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="item-aligned">
|
||||||
|
<SelectItem value="auto"> Auto </SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="segment in segments"
|
||||||
|
:key="segment.label"
|
||||||
|
:value="segment.value"
|
||||||
|
>
|
||||||
|
{{ segment.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</VeeField>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:disabled="processing"
|
||||||
|
variant="ghost"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
close();
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Prekliči
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="segment-change-form" :disabled="processing">
|
||||||
|
Potrdi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
@@ -30,14 +30,15 @@ import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
|||||||
import InputLabel from "@/Components/InputLabel.vue";
|
import InputLabel from "@/Components/InputLabel.vue";
|
||||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||||
|
import { toNumber } from "lodash";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
setting: Object,
|
setting: Object,
|
||||||
unassignedContracts: Object,
|
unassignedContracts: Object,
|
||||||
assignedContracts: Object,
|
assignedContracts: Object,
|
||||||
users: Array,
|
users: Array,
|
||||||
unassignedClients: Array,
|
unassignedClients: [Array, Object],
|
||||||
assignedClients: Array,
|
assignedClients: [Array, Object],
|
||||||
filters: Object,
|
filters: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
|
|||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const unassignedContractTable = ref(null);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
contract_uuid: null,
|
contract_uuid: null,
|
||||||
assigned_user_id: null,
|
assigned_user_id: null,
|
||||||
@@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
|
|||||||
console.log(selectedContractUuids.value);
|
console.log(selectedContractUuids.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleContractSelection(selected) {
|
||||||
|
selectedContractUuids.value = selected.map((val, i) => {
|
||||||
|
const num = toNumber(val);
|
||||||
|
|
||||||
|
return props.unassignedContracts.data[num].uuid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Format helpers (Slovenian formatting)
|
// Format helpers (Slovenian formatting)
|
||||||
|
|
||||||
// Initialize search and filter from URL params
|
// Initialize search and filter from URL params
|
||||||
@@ -296,6 +307,7 @@ function assignSelected() {
|
|||||||
bulkForm.contract_uuids = selectedContractUuids.value;
|
bulkForm.contract_uuids = selectedContractUuids.value;
|
||||||
bulkForm.post(route("fieldjobs.assign-bulk"), {
|
bulkForm.post(route("fieldjobs.assign-bulk"), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
unassignedContractTable.value.clearSelection();
|
||||||
selectedContractUuids.value = [];
|
selectedContractUuids.value = [];
|
||||||
bulkForm.contract_uuids = [];
|
bulkForm.contract_uuids = [];
|
||||||
},
|
},
|
||||||
@@ -304,7 +316,11 @@ function assignSelected() {
|
|||||||
|
|
||||||
function cancelAssignment(contract) {
|
function cancelAssignment(contract) {
|
||||||
const payload = { contract_uuid: contract.uuid };
|
const payload = { contract_uuid: contract.uuid };
|
||||||
form.transform(() => payload).post(route("fieldjobs.cancel"));
|
form
|
||||||
|
.transform(() => payload)
|
||||||
|
.post(route("fieldjobs.cancel"), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column definitions for DataTableNew2
|
// Column definitions for DataTableNew2
|
||||||
@@ -437,6 +453,7 @@ const assignedRows = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
|
ref="unassignedContractTable"
|
||||||
:columns="unassignedColumns"
|
:columns="unassignedColumns"
|
||||||
:data="unassignedRows"
|
:data="unassignedRows"
|
||||||
:meta="{
|
:meta="{
|
||||||
@@ -449,6 +466,8 @@ const assignedRows = computed(() =>
|
|||||||
links: unassignedContracts.links,
|
links: unassignedContracts.links,
|
||||||
}"
|
}"
|
||||||
row-key="uuid"
|
row-key="uuid"
|
||||||
|
:enable-row-selection="true"
|
||||||
|
@selection:change="handleContractSelection"
|
||||||
:page-size="props.unassignedContracts?.per_page || 10"
|
:page-size="props.unassignedContracts?.per_page || 10"
|
||||||
:page-size-options="[10, 15, 25, 50, 100]"
|
:page-size-options="[10, 15, 25, 50, 100]"
|
||||||
:show-toolbar="true"
|
:show-toolbar="true"
|
||||||
@@ -482,7 +501,10 @@ const assignedRows = computed(() =>
|
|||||||
<AppMultiSelect
|
<AppMultiSelect
|
||||||
v-model="filterUnassignedSelectedClient"
|
v-model="filterUnassignedSelectedClient"
|
||||||
:items="
|
:items="
|
||||||
(props.unassignedClients || []).map((client) => ({
|
(Array.isArray(props.unassignedClients)
|
||||||
|
? props.unassignedClients
|
||||||
|
: props.unassignedClients?.data || []
|
||||||
|
).map((client) => ({
|
||||||
value: client.uuid,
|
value: client.uuid,
|
||||||
label: client.person.full_name,
|
label: client.person.full_name,
|
||||||
}))
|
}))
|
||||||
@@ -497,14 +519,6 @@ const assignedRows = computed(() =>
|
|||||||
</AppPopover>
|
</AppPopover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-_select="{ row }">
|
|
||||||
<Checkbox
|
|
||||||
@update:model-value="
|
|
||||||
(checked) => toggleContractSelection(row.uuid, checked)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #cell-case_person="{ row }">
|
<template #cell-case_person="{ row }">
|
||||||
<Link
|
<Link
|
||||||
v-if="row.client_case?.uuid"
|
v-if="row.client_case?.uuid"
|
||||||
@@ -605,7 +619,10 @@ const assignedRows = computed(() =>
|
|||||||
<AppMultiSelect
|
<AppMultiSelect
|
||||||
v-model="filterAssignedSelectedClient"
|
v-model="filterAssignedSelectedClient"
|
||||||
:items="
|
:items="
|
||||||
(props.assignedClients || []).map((client) => ({
|
(Array.isArray(props.assignedClients)
|
||||||
|
? props.assignedClients
|
||||||
|
: props.assignedClients?.data || []
|
||||||
|
).map((client) => ({
|
||||||
value: client.uuid,
|
value: client.uuid,
|
||||||
label: client.person.full_name,
|
label: client.person.full_name,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ async function startImport() {
|
|||||||
|
|
||||||
<!-- Has Header Checkbox -->
|
<!-- Has Header Checkbox -->
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Checkbox id="has-header" v-model:checked="form.has_header" />
|
<Checkbox id="has-header" :model-value="form.has_header" />
|
||||||
<Label
|
<Label
|
||||||
for="has-header"
|
for="has-header"
|
||||||
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
|||||||
@@ -1094,6 +1094,16 @@ async function fetchEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadImport() {
|
||||||
|
if (!importId.value) return;
|
||||||
|
try {
|
||||||
|
const url = route("imports.download", { import: importId.value });
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Download failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simulation (generic or payments) state
|
// Simulation (generic or payments) state
|
||||||
const showPaymentSim = ref(false);
|
const showPaymentSim = ref(false);
|
||||||
const paymentSimLoading = ref(false);
|
const paymentSimLoading = ref(false);
|
||||||
@@ -1307,7 +1317,8 @@ async function fetchSimulation() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
:id="'show-missing-checkbox'"
|
:id="'show-missing-checkbox'"
|
||||||
:checked="showMissingEnabled"
|
:checked="showMissingEnabled"
|
||||||
@update:checked="
|
:model-value="showMissingEnabled"
|
||||||
|
@update:model-value="
|
||||||
(val) => {
|
(val) => {
|
||||||
showMissingEnabled = val;
|
showMissingEnabled = val;
|
||||||
saveImportOptions();
|
saveImportOptions();
|
||||||
@@ -1339,6 +1350,7 @@ async function fetchSimulation() {
|
|||||||
:can-process="canProcess"
|
:can-process="canProcess"
|
||||||
:selected-mappings-count="selectedMappingsCount"
|
:selected-mappings-count="selectedMappingsCount"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
|
@download="downloadImport"
|
||||||
@save-mappings="saveMappings"
|
@save-mappings="saveMappings"
|
||||||
@process-import="processImport"
|
@process-import="processImport"
|
||||||
@simulate="openSimulation"
|
@simulate="openSimulation"
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import {
|
|||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
ArrowDownOnSquareIcon,
|
ArrowDownOnSquareIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
} from "@heroicons/vue/24/outline";
|
} from "@heroicons/vue/24/outline";
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Badge } from '@/Components/ui/badge';
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
importId: [Number, String],
|
importId: [Number, String],
|
||||||
@@ -16,15 +17,30 @@ const props = defineProps({
|
|||||||
canProcess: Boolean,
|
canProcess: Boolean,
|
||||||
selectedMappingsCount: Number,
|
selectedMappingsCount: Number,
|
||||||
});
|
});
|
||||||
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]);
|
const emits = defineEmits([
|
||||||
|
"preview",
|
||||||
|
"save-mappings",
|
||||||
|
"process-import",
|
||||||
|
"simulate",
|
||||||
|
"download",
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<!-- Download button - always visible -->
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@click.prevent="$emit('preview')"
|
@click.prevent="$emit('download')"
|
||||||
:disabled="!importId"
|
:disabled="!importId"
|
||||||
|
title="Preznesi originalno uvozno datoteko"
|
||||||
>
|
>
|
||||||
|
<ArrowDownTrayIcon class="h-4 w-4" />
|
||||||
|
Prenos datoteko
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Other action buttons - only when not completed -->
|
||||||
|
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||||
|
<Button variant="secondary" @click.prevent="$emit('preview')" :disabled="!importId">
|
||||||
<EyeIcon class="h-4 w-4 mr-2" />
|
<EyeIcon class="h-4 w-4 mr-2" />
|
||||||
Predogled vrstic
|
Predogled vrstic
|
||||||
</Button>
|
</Button>
|
||||||
@@ -41,11 +57,9 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
|
|||||||
></span>
|
></span>
|
||||||
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
|
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
|
||||||
<span>Shrani preslikave</span>
|
<span>Shrani preslikave</span>
|
||||||
<Badge
|
<Badge v-if="selectedMappingsCount" variant="secondary" class="ml-2 text-xs">{{
|
||||||
v-if="selectedMappingsCount"
|
selectedMappingsCount
|
||||||
variant="secondary"
|
}}</Badge>
|
||||||
class="ml-2 text-xs"
|
|
||||||
>{{ selectedMappingsCount }}</Badge>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -66,4 +80,5 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
|
|||||||
Simulacija vnosa
|
Simulacija vnosa
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
|
import { ChevronRightIcon } from "@heroicons/vue/24/outline";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: Boolean,
|
show: Boolean,
|
||||||
limit: Number,
|
limit: Number,
|
||||||
@@ -14,20 +17,63 @@ const props = defineProps({
|
|||||||
truncated: Boolean,
|
truncated: Boolean,
|
||||||
hasHeader: Boolean,
|
hasHeader: Boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emits = defineEmits(['close','change-limit','refresh'])
|
const emits = defineEmits(['close','change-limit','refresh'])
|
||||||
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
|
|
||||||
|
// State
|
||||||
|
const selectedRow = ref(null);
|
||||||
|
const hideEmptyRows = ref(true);
|
||||||
|
|
||||||
|
// Filter out columns with empty headers
|
||||||
|
const visibleColumns = computed(() => {
|
||||||
|
if (!props.columns) return [];
|
||||||
|
return props.columns.filter(col => col && String(col).trim() !== '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if row is empty (first 2 columns are empty)
|
||||||
|
function isRowEmpty(row) {
|
||||||
|
if (!visibleColumns.value || visibleColumns.value.length === 0) return false;
|
||||||
|
const firstCols = visibleColumns.value.slice(0, 2);
|
||||||
|
return firstCols.every(col => !row[col] || String(row[col]).trim() === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtered rows
|
||||||
|
const visibleRows = computed(() => {
|
||||||
|
if (!props.rows) return [];
|
||||||
|
let filtered = props.rows;
|
||||||
|
if (hideEmptyRows.value) {
|
||||||
|
filtered = filtered.filter(r => !isRowEmpty(r));
|
||||||
|
}
|
||||||
|
return filtered.map((r, idx) => ({ ...r, index: idx + 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select row
|
||||||
|
function selectRow(row) {
|
||||||
|
selectedRow.value = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLimit(val) {
|
||||||
|
emits('change-limit', Number(val));
|
||||||
|
emits('refresh');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
|
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
|
||||||
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
<DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||||
<DialogHeader>
|
<!-- Header -->
|
||||||
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
|
<div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
|
||||||
</DialogHeader>
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<div class="flex items-center gap-3 pb-3 border-b">
|
<h2 class="text-xl font-semibold text-gray-900">CSV Preview</h2>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Showing {{ visibleRows.length }} of {{ rows.length }} rows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
|
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
|
||||||
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }">
|
<Select :model-value="String(limit)" @update:model-value="onLimit">
|
||||||
<SelectTrigger id="limit-select" class="w-24 h-8">
|
<SelectTrigger id="limit-select" class="w-24 h-8">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -43,43 +89,115 @@ function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refre
|
|||||||
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
|
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
|
||||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="hide-empty-rows"
|
||||||
|
:checked="hideEmptyRows"
|
||||||
|
@update:checked="(val) => hideEmptyRows = val"
|
||||||
|
/>
|
||||||
|
<Label for="hide-empty-rows" class="text-xs cursor-pointer">
|
||||||
|
Hide empty rows
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
|
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
|
||||||
Truncated at limit
|
Truncated at limit
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex-1 overflow-auto border rounded-lg">
|
|
||||||
<Table>
|
|
||||||
<TableHeader class="sticky top-0 bg-white z-10">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead class="w-16">#</TableHead>
|
|
||||||
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-if="loading">
|
|
||||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
|
||||||
Loading…
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow v-for="(r, idx) in rows" :key="idx">
|
|
||||||
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
|
|
||||||
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
|
|
||||||
{{ r[col] }}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow v-if="!loading && !rows.length">
|
|
||||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
|
||||||
No rows
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 pt-3 border-t">
|
<!-- Split View -->
|
||||||
Showing up to {{ limit }} rows from source file.
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
<!-- Left Panel - Row List -->
|
||||||
|
<div class="w-96 border-r bg-gray-50 overflow-y-auto">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-gray-500">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!visibleRows.length" class="p-8 text-center text-gray-500">
|
||||||
|
No rows to display
|
||||||
|
</div>
|
||||||
|
<div v-else class="divide-y">
|
||||||
|
<button
|
||||||
|
v-for="row in visibleRows"
|
||||||
|
:key="row.index"
|
||||||
|
@click="selectRow(row)"
|
||||||
|
class="w-full px-4 py-3 text-left hover:bg-white transition-colors"
|
||||||
|
:class="{
|
||||||
|
'bg-white shadow-sm': selectedRow?.index === row.index,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<!-- Row Number -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-semibold">
|
||||||
|
{{ row.index }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row Preview -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-xs font-semibold text-gray-900 mb-1">
|
||||||
|
Row #{{ row.index }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 truncate">
|
||||||
|
{{
|
||||||
|
visibleColumns.slice(0, 2).map(col => row[col]).filter(Boolean).join(' • ') || 'Empty row'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrow -->
|
||||||
|
<ChevronRightIcon class="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel - Row Details -->
|
||||||
|
<div v-if="selectedRow" class="flex-1 overflow-y-auto p-6">
|
||||||
|
<!-- Row Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Row #{{ selectedRow.index }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">Full row details</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row Data -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<dl class="grid grid-cols-1 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="col in visibleColumns"
|
||||||
|
:key="col"
|
||||||
|
class="flex items-start gap-3 py-2 border-b border-gray-200 last:border-0"
|
||||||
|
>
|
||||||
|
<dt class="text-sm font-medium text-gray-600 w-48 flex-shrink-0">
|
||||||
|
{{ col }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 flex-1 font-medium whitespace-pre-wrap break-words">
|
||||||
|
{{ selectedRow[col] || '—' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State for Right Panel -->
|
||||||
|
<div v-else class="flex-1 flex items-center justify-center text-gray-400">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-5xl mb-3">📄</div>
|
||||||
|
<p class="text-sm">Select a row to view details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500">
|
||||||
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
|
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
|
||||||
|
• Click a row to view full details
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
import {
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
Table,
|
||||||
import { Checkbox } from '@/Components/ui/checkbox';
|
TableBody,
|
||||||
import { Input } from '@/Components/ui/input';
|
TableCell,
|
||||||
import { Badge } from '@/Components/ui/badge';
|
TableHead,
|
||||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
rows: Array,
|
rows: Array,
|
||||||
@@ -19,12 +33,12 @@ const props = defineProps({
|
|||||||
mappingError: String,
|
mappingError: String,
|
||||||
show: { type: Boolean, default: true },
|
show: { type: Boolean, default: true },
|
||||||
fieldsForEntity: Function,
|
fieldsForEntity: Function,
|
||||||
})
|
});
|
||||||
const emits = defineEmits(['update:rows','save'])
|
const emits = defineEmits(["update:rows", "save"]);
|
||||||
|
|
||||||
function duplicateTarget(row) {
|
function duplicateTarget(row) {
|
||||||
if(!row || !row.entity || !row.field) return false
|
if (!row || !row.entity || !row.field) return false;
|
||||||
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
|
return props.duplicateTargets?.has?.(row.entity + "." + row.field) || false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -32,39 +46,63 @@ function duplicateTarget(row){
|
|||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<h3 class="font-semibold">
|
<h3 class="font-semibold">
|
||||||
Detected Columns
|
Detected Columns
|
||||||
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge>
|
<Badge variant="outline" class="ml-2 text-[10px]">{{
|
||||||
|
detected?.has_header ? "header" : "positional"
|
||||||
|
}}</Badge>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-xs text-muted-foreground">
|
<div class="text-xs text-muted-foreground">
|
||||||
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}
|
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }},
|
||||||
|
delimiter: {{ detected?.delimiter || "auto" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p>
|
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">
|
||||||
|
{{ detectedNote }}
|
||||||
|
</p>
|
||||||
<div class="relative border rounded-lg">
|
<div class="relative border rounded-lg">
|
||||||
<ScrollArea class="h-[420px]">
|
<ScrollArea class="h-[420px]">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||||
<TableRow class="hover:bg-transparent">
|
<TableRow class="hover:bg-transparent">
|
||||||
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead>
|
<TableHead class="w-[180px] bg-muted/95 backdrop-blur"
|
||||||
|
>Source column</TableHead
|
||||||
|
>
|
||||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
|
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
|
||||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
|
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
|
||||||
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
|
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
|
||||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
|
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
|
||||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
|
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
|
||||||
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
|
<TableHead class="w-[130px] bg-muted/95 backdrop-blur"
|
||||||
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
|
>Apply mode</TableHead
|
||||||
|
>
|
||||||
|
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur"
|
||||||
|
>Skip</TableHead
|
||||||
|
>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''">
|
<TableRow
|
||||||
|
v-for="(row, idx) in rows"
|
||||||
|
:key="idx"
|
||||||
|
:class="duplicateTarget(row) ? 'bg-destructive/10' : ''"
|
||||||
|
>
|
||||||
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
|
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted">
|
<Select
|
||||||
|
:model-value="row.entity || ''"
|
||||||
|
@update:model-value="(val) => (row.entity = val || '')"
|
||||||
|
:disabled="isCompleted"
|
||||||
|
>
|
||||||
<SelectTrigger class="h-8 text-xs">
|
<SelectTrigger class="h-8 text-xs">
|
||||||
<SelectValue placeholder="Select entity..." />
|
<SelectValue placeholder="Select entity..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
|
<SelectItem
|
||||||
|
v-for="opt in entityOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>{{ opt.label }}</SelectItem
|
||||||
|
>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -72,16 +110,26 @@ function duplicateTarget(row){
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Select
|
<Select
|
||||||
:model-value="row.field || ''"
|
:model-value="row.field || ''"
|
||||||
@update:model-value="(val) => row.field = val || ''"
|
@update:model-value="(val) => (row.field = val || '')"
|
||||||
:disabled="isCompleted"
|
:disabled="isCompleted"
|
||||||
:class="duplicateTarget(row) ? 'border-destructive' : ''"
|
:class="duplicateTarget(row) ? 'border-destructive' : ''"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''">
|
<SelectTrigger
|
||||||
|
class="h-8 text-xs"
|
||||||
|
:class="
|
||||||
|
duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
<SelectValue placeholder="Select field..." />
|
<SelectValue placeholder="Select field..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem>
|
<SelectItem
|
||||||
|
v-for="f in fieldsForEntity(row.entity)"
|
||||||
|
:key="f"
|
||||||
|
:value="f"
|
||||||
|
>{{ f }}</SelectItem
|
||||||
|
>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -101,7 +149,7 @@ function duplicateTarget(row){
|
|||||||
<Select
|
<Select
|
||||||
v-if="row.field === 'meta'"
|
v-if="row.field === 'meta'"
|
||||||
:model-value="(row.options ||= {}).type || 'string'"
|
:model-value="(row.options ||= {}).type || 'string'"
|
||||||
@update:model-value="(val) => (row.options ||= {}).type = val"
|
@update:model-value="(val) => ((row.options ||= {}).type = val)"
|
||||||
:disabled="isCompleted"
|
:disabled="isCompleted"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 text-xs">
|
<SelectTrigger class="h-8 text-xs">
|
||||||
@@ -119,7 +167,13 @@ function duplicateTarget(row){
|
|||||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted">
|
<Select
|
||||||
|
:model-value="row.transform || 'none'"
|
||||||
|
@update:model-value="
|
||||||
|
(val) => (row.transform = val === 'none' ? '' : val)
|
||||||
|
"
|
||||||
|
:disabled="isCompleted"
|
||||||
|
>
|
||||||
<SelectTrigger class="h-8 text-xs">
|
<SelectTrigger class="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -134,7 +188,11 @@ function duplicateTarget(row){
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted">
|
<Select
|
||||||
|
:model-value="row.apply_mode || 'both'"
|
||||||
|
@update:model-value="(val) => (row.apply_mode = val)"
|
||||||
|
:disabled="isCompleted"
|
||||||
|
>
|
||||||
<SelectTrigger class="h-8 text-xs">
|
<SelectTrigger class="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -149,20 +207,31 @@ function duplicateTarget(row){
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" />
|
<Checkbox
|
||||||
|
:model-value="row.skip"
|
||||||
|
@update:model-value="(val) => (row.skip = val)"
|
||||||
|
:disabled="isCompleted"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2">
|
<div
|
||||||
|
v-if="mappingSaved"
|
||||||
|
class="text-sm text-emerald-700 mt-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
|
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
|
||||||
<span>{{ mappingSavedCount }} mappings saved</span>
|
<span>{{ mappingSavedCount }} mappings saved</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div>
|
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">
|
||||||
|
{{ mappingError }}
|
||||||
|
</div>
|
||||||
<div v-if="missingCritical?.length" class="mt-2">
|
<div v-if="missingCritical?.length" class="mt-2">
|
||||||
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
|
<Badge variant="destructive" class="text-xs"
|
||||||
|
>Missing critical: {{ missingCritical.join(", ") }}</Badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user