Compare commits

...

19 Commits

Author SHA1 Message Date
6424dc4e85 minimize cookie and simplify roles
All checks were successful
Build and Publish / build-release (push) Successful in 2m11s
2026-05-15 13:06:16 -05:00
17710ab47a add ca where it expects it
All checks were successful
Build and Publish / build-release (push) Successful in 42s
2026-05-14 16:51:00 -05:00
99387fd7e2 use csrf token
All checks were successful
Build and Publish / build-release (push) Successful in 44s
2026-05-14 16:47:25 -05:00
38b03ebab5 set ssr to false
All checks were successful
Build and Publish / build-release (push) Successful in 43s
2026-05-14 16:29:20 -05:00
f25e663175 set auth origin
All checks were successful
Build and Publish / build-release (push) Successful in 57s
2026-05-14 16:06:09 -05:00
69c317cc96 fix env propagation
All checks were successful
Build and Publish / build-release (push) Successful in 59s
2026-05-14 16:02:40 -05:00
fe91c2e8f1 use runtime config
All checks were successful
Build and Publish / build-release (push) Successful in 1m1s
2026-05-14 15:52:33 -05:00
2ed75599bc create oidcapp
All checks were successful
Build and Publish / build-release (push) Successful in 1m1s
2026-05-14 15:19:53 -05:00
bb6ee40741 get origin from env
All checks were successful
Build and Publish / build-release (push) Successful in 59s
2026-05-14 13:06:52 -05:00
541ae3ac80 remive colon
All checks were successful
Build and Publish / build-release (push) Successful in 55s
2026-05-14 12:24:21 -05:00
271fff9112 add zitadel values
Some checks failed
Build and Publish / build-release (push) Failing after 56s
2026-05-14 12:22:02 -05:00
32949c2037 fix hash
All checks were successful
Build and Publish / build-release (push) Successful in 56s
2026-05-14 12:17:10 -05:00
3a52768b97 fix auth
Some checks failed
Build and Publish / build-release (push) Failing after 1m31s
2026-05-14 12:12:03 -05:00
f19a727ef0 add quick leads
All checks were successful
Build and Publish / build-release (push) Successful in 4m11s
2026-05-04 13:06:09 -05:00
53bbdca525 bug fixes
All checks were successful
Build and Publish / build-release (push) Successful in 4m19s
2026-04-30 16:41:34 -05:00
7d5e198156 add node extra certs
All checks were successful
Build and Publish / build-release (push) Successful in 1m9s
2026-04-29 16:56:06 -05:00
03610c802b removed exposed ports
All checks were successful
Build and Publish / build-release (push) Successful in 2m53s
2026-04-29 16:44:52 -05:00
9542a5373e fix env config
Some checks failed
Build and Publish / build-release (push) Failing after 50s
2026-04-29 16:42:50 -05:00
f6b8ba3030 update readme
Some checks failed
Build and Publish / build-release (push) Failing after 8s
2026-04-29 16:35:39 -05:00
35 changed files with 1942 additions and 912 deletions

233
README.md
View File

@@ -1,75 +1,206 @@
# Nuxt Minimal Starter
# Policy UI
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
Nuxt.js application for managing insurance policies, quotes, and customer relationships.
## Setup
## Overview
Make sure to install dependencies:
Policy UI is a modern web application built with:
- **Nuxt 4** - Vue 3 framework with server-side rendering
- **@nuxt/ui** - Component library for consistent UI
- **Tailwind CSS** - Utility-first CSS framework
- **TypeScript** - Type-safe development
## Features
- Policy management and tracking
- Quote comparison and selection
- Customer relationship management
- Workload task management (back-office)
- Document handling and generation
- Responsive design
## Development
### Prerequisites
- **Nix** (recommended) or Node.js 20+ and pnpm 10+
### Using Nix (Recommended)
The project uses Nix for reproducible development and builds.
```bash
# npm
npm install
# Enter the development shell
nix develop
# pnpm
# Install dependencies (first time only)
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
# Start development server
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
The application will be available at `http://localhost:3000`.
Build the application for production:
### Using Node.js/pnpm
If you don't have Nix installed:
```bash
# npm
npm run build
# Install dependencies
pnpm install
# pnpm
# Start development server
pnpm dev
```
## Building
### With Nix
```bash
# Build the application
nix build .#policy-ui
# The built application will be in the Nix store
```
### With pnpm
```bash
# Build the application
pnpm build
# yarn
yarn build
# bun
bun run build
# Preview production build
pnpm preview
```
Locally preview production build:
## Infrastructure
### Nix Flake
The project uses a Nix flake for reproducible builds and Docker image creation:
- **`packages.policy-ui`** - Builds the Nuxt application
- **`packages.dockerImage`** - Creates a Docker image with Node.js and the built app
- **`devShells.default`** - Provides Node.js, pnpm, and Helm in the development shell
### Docker Image
The Docker image is built using Nix and includes:
- Node.js runtime
- Built Nuxt application (`.output` and `node_modules`)
- Configured to run on port 3000
### Helm Chart
A Helm chart is provided for Kubernetes deployment at `ops/chart/`:
```bash
# npm
npm run preview
# Install dependencies
helm repo add bjw-s https://bjw-s-labs.github.io/helm-charts
helm dependency build ops/chart
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
# Install the chart
helm install policy-ui ops/chart --namespace <namespace>
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
The chart includes:
- Deployment with 1 replica (configurable)
- ClusterIP service on port 3000
- Health checks (liveness/readiness)
- Environment variables for API endpoints
- Ingress support (disabled by default)
### CI/CD
The `.gitea/workflows/build-and-publish.yaml` workflow:
- Triggers on push to `main` branch
- Builds Docker image using Nix
- Pushes to Gitea Container Registry
- Packages and pushes Helm chart to Gitea Helm registry
## Configuration
### Environment Variables
The application uses the following environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `NUXT_PUBLIC_CUSTOMER_API_BASE` | Customer API base URL | `https://dev.api.corredorconect.com/customer/api/v1` |
| `NUXT_PUBLIC_POLICY_API_BASE` | Policy API base URL | `https://dev.api.corredorconect.com/policy/api/v1` |
| `NUXT_PUBLIC_PROVIDERS_API_BASE` | Providers API base URL | `https://dev.api.corredorconect.com/provider/api/v1` |
| `NUXT_PUBLIC_WORKLOAD_API_BASE` | Workload API base URL | `https://dev.api.corredorconect.com/workload/api/v1` |
| `NUXT_PUBLIC_DOCUMENT_API_BASE` | Document API base URL | `https://dev.api.corredorconect.com/document/api` |
| `NUXT_PUBLIC_POLICY_API_TOKEN` | Policy API authentication token | Required |
### API Endpoints
The application connects to the following backend services:
- **Customer API** - Customer and policy data
- **Policy API** - Policy management and quotes
- **Providers API** - Insurance provider information
- **Workload API** - Back-office task management
- **Document API** - Document storage and retrieval
## Deployment
### Kubernetes
1. Create the required secret:
```bash
kubectl create secret generic policy-ui-secrets \
--from-literal=policyApiToken='your-token-here' \
--namespace=<namespace>
```
2. Install the Helm chart:
```bash
helm install policy-ui ops/chart --namespace <namespace>
```
3. For production, override values:
```bash
helm install policy-ui ops/chart --namespace <namespace> \
--set controllers.main.containers.main.image.tag=<version> \
--set ingress.main.enabled=true \
--set ingress.main.hosts[0].host=policy-ui.example.com
```
### Docker
```bash
# Build the image with Nix
nix build .#dockerImage
# Load the image
docker load < result
# Run the container
docker run -p 3000:3000 \
-e NUXT_PUBLIC_POLICY_API_TOKEN=your-token \
gitea.corredorconect.com/software-engineering/policy-ui:latest
```
## Project Structure
```
policy-ui/
├── app/ # Nuxt app directory
│ ├── components/ # Vue components
│ ├── pages/ # File-based routing
│ └── assets/ # Static assets
├── ops/ # Infrastructure
│ └── chart/ # Helm chart
├── .gitea/ # CI/CD workflows
│ └── workflows/
├── flake.nix # Nix flake configuration
└── package.json # Node.js dependencies
```
## License
[Your License Here]

View File

@@ -51,7 +51,7 @@ const props = defineProps<Props>()
</div>
<!-- Policy Details -->
<div v-if="task.task_info?.policy_details">
<div v-if="task.task_info?.insured_object">
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Policy Details</h3>
<div class="border rounded-lg overflow-hidden">
<table class="w-full text-sm">
@@ -62,7 +62,7 @@ const props = defineProps<Props>()
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(value, key) in task.task_info.policy_details" :key="key">
<tr v-for="(value, key) in task.task_info.insured_object" :key="key">
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
</tr>

View File

@@ -11,6 +11,7 @@ const emit = defineEmits<{
const route = useRoute()
const router = useRouter()
const { data: session, status, signOut } = useAuth()
const isHome = computed(() => route.path === '/')
const colorMode = useColorMode()
const isDark = computed({
@@ -25,10 +26,25 @@ const isDark = computed({
const userMenuOpen = ref(false)
const userMenuRoot = ref<HTMLElement | null>(null)
const user = computed(() => session.value?.user)
const userEmail = computed(() => user.value?.email || 'user@example.com')
const userName = computed(() => user.value?.name || 'User')
const isAuthenticated = computed(() => status.value === 'authenticated')
function closeUserMenu() {
userMenuOpen.value = false
}
async function handleLogout() {
try {
userMenuOpen.value = false
await signOut({ callbackUrl: '/login', redirect: true })
} catch (error) {
console.error('Logout failed:', error)
await navigateTo('/login')
}
}
function onDocClick(e: MouseEvent) {
const userEl = userMenuRoot.value
if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) {
@@ -78,7 +94,7 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
Quick Lead
</button>
</NuxtLink>
<NuxtLink to="/quotes">
<NuxtLink to="/quotes/new">
<button type="button" class="app-topbar-action-btn app-topbar-action-primary">
<UIcon name="i-heroicons-document-text" style="width: 13px; height: 13px;" />
New quote
@@ -119,6 +135,8 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
</span>
</NuxtLink>
<LayoutOrgSelector />
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
<!-- User / Account -->
@@ -146,6 +164,10 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
v-show="userMenuOpen"
class="absolute right-0 top-[calc(100%+8px)] z-50 w-56 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1 shadow-xl ring-1 ring-black/5"
>
<div class="px-3 py-2 border-b border-[var(--sidebar-border)]">
<p class="text-sm font-medium text-[var(--text-primary)]">{{ userName }}</p>
<p class="text-xs text-[var(--text-muted)]">{{ userEmail }}</p>
</div>
<NuxtLink
to="/account"
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
@@ -163,17 +185,13 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
Software settings
</NuxtLink>
<div class="my-1 border-t border-[var(--sidebar-border)]" />
<div class="px-3 py-1.5">
<p class="text-[12px] font-medium text-[var(--text-primary)]">Session (mock)</p>
<p class="text-[11px] text-[var(--text-muted)]">broker@demo.com</p>
</div>
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
disabled
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)] hover:bg-[var(--brand-faint)] hover:text-[var(--text-primary)] transition"
@click="handleLogout"
>
<UIcon name="i-heroicons-arrow-right-on-rectangle" class="h-4 w-4" />
Sign out (soon)
Sign out
</button>
</div>
</Transition>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
const { organizations, selectedOrg, selectOrg } = useOrganizationSelection()
const dropdownOpen = ref(false)
const dropdownRoot = ref<HTMLElement | null>(null)
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
}
function closeDropdown() {
dropdownOpen.value = false
}
function onDocClick(e: MouseEvent) {
const el = dropdownRoot.value
if (el && dropdownOpen.value && !el.contains(e.target as Node)) {
dropdownOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onDocClick))
onUnmounted(() => document.removeEventListener('click', onDocClick))
</script>
<template>
<div v-if="organizations.length > 0" ref="dropdownRoot" class="org-selector-root">
<button
type="button"
class="org-selector-btn"
aria-label="Organization selector"
:aria-expanded="dropdownOpen"
@click.stop="toggleDropdown"
>
<UIcon name="i-heroicons-building-office" style="width: 13px; height: 13px; flex-shrink: 0;" />
<span class="org-selector-label">{{ selectedOrg?.orgSubDomain ?? 'Org' }}</span>
<UIcon name="i-heroicons-chevron-down" style="width: 8px; height: 8px; opacity: 0.4; flex-shrink: 0;" />
</button>
<div
v-show="dropdownOpen"
class="org-dropdown"
>
<button
v-for="org in organizations"
:key="org.orgId"
type="button"
class="org-option"
:class="{ 'org-option-active': org.orgId === selectedOrg?.orgId }"
@click="selectOrg(org.orgId); closeDropdown()"
>
<UIcon
name="i-heroicons-check"
class="shrink-0"
:class="org.orgId === selectedOrg?.orgId ? 'opacity-100' : 'opacity-0'"
style="width: 14px; height: 14px;"
/>
<span class="org-option-label">{{ org.orgSubDomain }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.org-selector-root {
position: relative;
flex-shrink: 0;
}
.org-selector-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
line-height: normal;
color: #8a8a86;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 6px;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
box-sizing: content-box;
height: auto;
}
.org-selector-btn:hover {
color: var(--text-primary);
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.1);
}
.org-selector-label {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-dropdown {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 50;
min-width: 180px;
max-width: 280px;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--sidebar-border);
background: var(--surface);
padding: 4px 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.03);
}
.org-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
text-align: left;
font-size: 12px;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: background 100ms ease;
}
.org-option:hover {
background: var(--brand-faint);
color: var(--text-primary);
}
.org-option-active {
color: var(--text-primary);
font-weight: 500;
}
.org-option-label {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,79 @@
export interface OrganizationInfo {
orgId: string
orgDomain: string
role: string
}
function extractSubdomain(domain: string): string {
const parts = domain.split('.')
return parts.length > 1 ? parts[0] : domain
}
const STORAGE_KEY = 'policy-ui.selected-org-id'
export function useOrganizationSelection() {
const { data: session } = useAuth()
const organizations = computed<OrganizationInfo[]>(() => {
const allOrgRoles = (session.value?.user as any)?.roles as Record<string, Record<string, string>> | undefined
if (!allOrgRoles) {
return []
}
const result: OrganizationInfo[] = []
for (const [role, orgMap] of Object.entries(allOrgRoles)) {
for (const [orgId, orgDomain] of Object.entries(orgMap)) {
if (!result.find(o => o.orgId === orgId)) {
result.push({ orgId, orgDomain: orgDomain as string, role, orgSubDomain: extractSubdomain(orgDomain) })
}
}
}
result.sort((a, b) => a.orgDomain.localeCompare(b.orgDomain))
return result
})
// All unique org IDs the user has access to
const orgIds = computed<string[]>(() => organizations.value.map(o => o.orgId))
// Persisted selected org
const selectedOrgId = ref<string | null>(null)
onMounted(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && orgIds.value.includes(stored)) {
selectedOrgId.value = stored
} else if (organizations.value.length > 0 && !selectedOrgId.value) {
const defaultOrgId = organizations.value[0]!.orgId
selectedOrgId.value = defaultOrgId
localStorage.setItem(STORAGE_KEY, defaultOrgId)
}
})
watch(orgIds, (ids) => {
if (ids.length > 0) {
const firstId = ids[0]!
if (!ids.includes(selectedOrgId.value ?? '')) {
selectedOrgId.value = firstId
localStorage.setItem(STORAGE_KEY, firstId)
}
}
})
const selectOrg = (orgId: string) => {
selectedOrgId.value = orgId
localStorage.setItem(STORAGE_KEY, orgId)
}
const selectedOrg = computed<OrganizationInfo | undefined>(() => {
if (!selectedOrgId.value) return undefined
return organizations.value.find(o => o.orgId === selectedOrgId.value)
})
return {
organizations,
selectedOrgId,
selectedOrg,
selectOrg,
}
}

7
app/middleware/auth.ts Normal file
View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware((to) => {
const { status } = useAuth()
if (status.value === 'unauthenticated' && to.path !== '/login') {
return navigateTo('/login')
}
})

View File

@@ -188,7 +188,7 @@ const customerPolicies = computed(() => policiesData.value?.data ?? [])
<span>Customer Information</span>
</div>
<div class="p-4 text-sm text-gray-600">
<p>This customer has {{ customerPolicies.length }} policy{{ customerPolicies.length !== 1 ? 'ies' : '' }} on file.</p>
<p>This customer has {{ customerPolicies.length }} polic{{ customerPolicies.length === 1 ? 'y' : 'ies' }} on file.</p>
</div>
</div>
</div>

View File

@@ -47,6 +47,11 @@ const { data, pending, refresh } = useCustomer('/customers', {
})
})
// Ensure data is refreshed when page loads
onMounted(() => {
refresh()
})
const customers = computed(() => data.value?.data ?? [])
const meta = computed(() => data.value?.meta)

View File

@@ -1,8 +1,270 @@
<script setup lang="ts">
const route = useRoute()
await navigateTo({ path: '/registration/client', query: route.query }, { replace: true })
import type { SelectItem } from '@nuxt/ui'
const router = useRouter()
const submitting = ref(false)
const toast = useToast()
const { $customer } = useNuxtApp()
const customerType = ref<'individual' | 'corporate'>('individual')
const individualForm = ref({
first_name: '',
last_name: '',
email: '',
phone: '',
document_id: '',
birth_date: '',
address: ''
})
const corporateForm = ref({
legal_name: '',
commercial_name: '',
email: '',
phone: '',
ruc: '',
legal_rep_name: '',
legal_rep_document_id: '',
address: ''
})
const isValid = computed(() => {
if (customerType.value === 'individual') {
return !!(
individualForm.value.first_name &&
individualForm.value.last_name &&
individualForm.value.email &&
individualForm.value.document_id &&
individualForm.value.birth_date
)
} else {
return !!(
corporateForm.value.legal_name &&
corporateForm.value.email &&
corporateForm.value.ruc &&
corporateForm.value.legal_rep_name &&
corporateForm.value.legal_rep_document_id
)
}
})
async function submit() {
submitting.value = true
try {
const body = customerType.value === 'individual'
? {
customer_type: 'individual',
...individualForm.value
}
: {
customer_type: 'corporate',
...corporateForm.value
}
const data = await $customer('/customers', { method: 'POST', body }) as any
toast.add({ title: 'Customer created successfully', color: 'green' })
router.push(`/customers/${data.id}`)
} catch (e: any) {
toast.add({
title: 'Failed to create customer',
description: e?.data?.error ?? e.message,
color: 'red'
})
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="p-8 text-sm text-[var(--text-muted)]">Redirecting...</div>
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
<!-- Header -->
<div class="flex items-center gap-4">
<NuxtLink to="/customers">
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Customers</UButton>
</NuxtLink>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Customer</h1>
<p class="text-[13px] text-[var(--text-muted)]">Register a new customer in the system</p>
</div>
</div>
<!-- Customer Type Selection -->
<UCard>
<template #header>
<p class="font-semibold text-[var(--text-primary)]">Customer Type</p>
</template>
<div class="flex gap-4">
<div
class="flex-1 border-2 rounded-xl p-4 text-center transition-all cursor-pointer"
:class="[
customerType === 'individual'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-[var(--surface)] hover:border-gray-300'
]"
@click="customerType = 'individual'"
>
<UIcon name="i-heroicons-user" class="w-8 h-8 mx-auto mb-2" :class="customerType === 'individual' ? 'text-primary-500' : 'text-gray-400'" />
<p class="font-medium text-sm" :class="customerType === 'individual' ? 'text-primary-700' : 'text-gray-600'">Individual</p>
</div>
<div
class="flex-1 border-2 rounded-xl p-4 text-center transition-all cursor-pointer"
:class="[
customerType === 'corporate'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-[var(--surface)] hover:border-gray-300'
]"
@click="customerType = 'corporate'"
>
<UIcon name="i-heroicons-building-office" class="w-8 h-8 mx-auto mb-2" :class="customerType === 'corporate' ? 'text-primary-500' : 'text-gray-400'" />
<p class="font-medium text-sm" :class="customerType === 'corporate' ? 'text-primary-700' : 'text-gray-600'">Corporate</p>
</div>
</div>
</UCard>
<!-- Individual Form -->
<template v-if="customerType === 'individual'">
<UCard>
<template #header>
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-user" class="w-4 h-4" />
Personal Information
</p>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="First Name" required class="col-span-2 md:col-span-1">
<UInput v-model="individualForm.first_name" placeholder="Juan" class="w-full" />
</UFormField>
<UFormField label="Last Name" required class="col-span-2 md:col-span-1">
<UInput v-model="individualForm.last_name" placeholder="Pérez" class="w-full" />
</UFormField>
<UFormField label="Email" required>
<UInput v-model="individualForm.email" type="email" placeholder="juan@example.com" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="individualForm.phone" placeholder="+507-1234-5678" class="w-full" />
</UFormField>
<UFormField label="Document ID" required>
<UInput v-model="individualForm.document_id" placeholder="8-123-456" class="w-full" />
</UFormField>
<UFormField label="Date of Birth" required>
<UInput v-model="individualForm.birth_date" type="date" class="w-full" />
</UFormField>
<UFormField label="Address" class="col-span-2">
<UInput v-model="individualForm.address" placeholder="Calle 50, Panama City" class="w-full" />
</UFormField>
</div>
</UCard>
</template>
<!-- Corporate Form -->
<template v-else>
<UCard>
<template #header>
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
Company Information
</p>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Legal Name" required class="col-span-2">
<UInput v-model="corporateForm.legal_name" placeholder="Empresa XYZ S.A." class="w-full" />
</UFormField>
<UFormField label="Commercial Name">
<UInput v-model="corporateForm.commercial_name" placeholder="XYZ Corp" class="w-full" />
</UFormField>
<UFormField label="RUC" required>
<UInput v-model="corporateForm.ruc" placeholder="123456-1-123456" class="w-full" />
</UFormField>
<UFormField label="Email" required>
<UInput v-model="corporateForm.email" type="email" placeholder="contact@empresa.com" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="corporateForm.phone" placeholder="+507-1234-5678" class="w-full" />
</UFormField>
<UFormField label="Legal Representative Name" required>
<UInput v-model="corporateForm.legal_rep_name" placeholder="Carlos López" class="w-full" />
</UFormField>
<UFormField label="Legal Representative Document ID" required>
<UInput v-model="corporateForm.legal_rep_document_id" placeholder="8-789-012" class="w-full" />
</UFormField>
<UFormField label="Address" class="col-span-2">
<UInput v-model="corporateForm.address" placeholder="Calle 100, Panama City" class="w-full" />
</UFormField>
</div>
</UCard>
</template>
<!-- Submit -->
<div class="flex justify-end gap-3">
<NuxtLink to="/customers">
<UButton color="gray" variant="soft">Cancel</UButton>
</NuxtLink>
<UButton
color="primary"
icon="i-heroicons-check"
:loading="submitting"
:disabled="!isValid"
@click="submit"
>
Create Customer
</UButton>
</div>
</div>
</template>
<style scoped>
/* Prevent width expansion when dropdowns open */
:deep(.grid) {
min-width: 0;
width: 100%;
}
:deep(.UFormField) {
min-width: 0;
width: 100%;
}
:deep(.UInput) {
min-width: 0;
width: 100%;
}
:deep(.USelect) {
min-width: 0;
width: 100%;
}
/* Prevent dropdown menus from expanding container */
:deep([role="listbox"]) {
position: fixed !important;
z-index: 50;
min-width: 200px;
max-width: 300px;
}
/* Ensure form fields don't cause expansion */
:deep(.space-y-4) {
min-width: 0;
width: 100%;
overflow: hidden;
}
/* Prevent any element from expanding beyond container */
:deep(*) {
max-width: 100%;
}
/* Specific fix for dropdown positioning */
:deep(.USelectMenuPopover) {
position: fixed !important;
z-index: 100;
}
/* Ensure form stability */
:deep(.UCard) {
width: 100%;
min-width: 0;
}
</style>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
/* ── Time ── */
const { data: session } = useAuth()
const userName = computed(() => session.value?.user?.name || 'User')
const timeGreeting = computed(() => {
const h = new Date().getHours()
if (h < 12) return 'Good morning'
@@ -18,7 +21,7 @@ const currentDate = computed(() =>
<!-- Greeting -->
<div class="mb-12">
<h1 class="text-3xl font-semibold tracking-tight text-[var(--text-primary)]">
{{ timeGreeting }}, User
{{ timeGreeting }}, {{ userName }}
</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ currentDate }}</p>
</div>

View File

@@ -17,11 +17,22 @@ const emails = ref<Record<string, string>>({
const roles = ['quotes', 'claims', 'renewals', 'billing', 'support']
const label = computed(() => {
const getProviderLabel = computed(() => {
if (!provider.value) return ''
return provider.value.name || 'Unknown'
})
const getRoleLabel = (role: string) => {
const labels: Record<string, string> = {
quotes: 'Quotes',
claims: 'Claims',
renewals: 'Renewals',
billing: 'Billing',
support: 'Support'
}
return labels[role] || role
}
// templates and default_templates come directly from provider
const templates = computed(() => provider.value?.templates ?? {})
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
@@ -81,7 +92,7 @@ async function toggleTemplate(templateId: string, active: boolean, policyType: s
try {
await $providers(`/providers/${providerId}/templates/${templateId}/${path}`, {
method: 'POST',
body: { policy_type: policyType, client_type: client_type }
body: { policy_type: policyType, client_type: clientType }
})
toast.add({ title: `Template ${active ? 'deactivated' : 'activated'}`, color: 'green' })
await refresh()
@@ -182,7 +193,7 @@ const clientTypeColor = (ct: string) =>
from these slots.
</p>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField v-for="role in roles" :key="role" :label="label(role)">
<UFormField v-for="role in roles" :key="role" :label="getRoleLabel(role)">
<UInput v-model="emails[role]" type="email" placeholder="name@carrier.com" class="w-full" />
</UFormField>
</div>

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
const search = ref('')
const { data, pending, refresh } = useProviders('/providers')
// Ensure data is refreshed when page loads
onMounted(() => {
refresh()
})
const providers = computed(() => {
const list = data.value?.data ?? []
if (!search.value) return list

View File

@@ -5,10 +5,10 @@ const toast = useToast()
const { $providers } = useNuxtApp()
const form = ref({
name: '', email: '', phone: '', contact_name: '', ruc: '', address: ''
provider_id: '', name: '', email: '', phone: '', contact_name: '', ruc: '', address: ''
})
const isValid = computed(() => form.value.name && form.value.email)
const isValid = computed(() => form.value.provider_id && form.value.name && form.value.email)
async function submit() {
submitting.value = true
@@ -45,6 +45,10 @@ async function submit() {
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<UFormField label="Provider ID" required>
<UInput v-model="form.provider_id" placeholder="seguros-abc" class="w-full" />
<p class="text-xs text-gray-500 mt-1">Alphanumeric identifier (letters and numbers only)</p>
</UFormField>
<UFormField label="Company Name" required class="col-span-2">
<UInput v-model="form.name" placeholder="Seguros Panama S.A." class="w-full" />
</UFormField>
@@ -77,3 +81,58 @@ async function submit() {
</UCard>
</div>
</template>
<style scoped>
/* Prevent width expansion when dropdowns open */
:deep(.grid) {
min-width: 0;
width: 100%;
}
:deep(.UFormField) {
min-width: 0;
width: 100%;
}
:deep(.UInput) {
min-width: 0;
width: 100%;
}
:deep(.USelect) {
min-width: 0;
width: 100%;
}
/* Prevent dropdown menus from expanding container */
:deep([role="listbox"]) {
position: fixed !important;
z-index: 50;
min-width: 200px;
max-width: 300px;
}
/* Ensure form fields don't cause expansion */
:deep(.space-y-4) {
min-width: 0;
width: 100%;
overflow: hidden;
}
/* Prevent any element from expanding beyond container */
:deep(*) {
max-width: 100%;
}
/* Specific fix for dropdown positioning */
:deep(.USelectMenuPopover) {
position: fixed !important;
z-index: 100;
}
/* Ensure form stability */
:deep(.UCard) {
width: 100%;
min-width: 0;
}
</style>

85
app/pages/login.vue Normal file
View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { AuthProvider } from '#auth'
definePageMeta({
auth: false
})
const route = useRoute()
const { getCsrfToken, getProviders } = useAuth()
const csrfToken = ref('')
const provider = ref<AuthProvider | undefined>(undefined)
const error = ref('')
const loading = ref(true)
const callbackUrl = computed(() => (route.query.callbackUrl as string) || '/')
onMounted(async () => {
try {
const [providersData, tokenData] = await Promise.all([
getProviders(),
getCsrfToken()
])
const token = tokenData || ''
csrfToken.value = token
provider.value = (providersData as Record<string, AuthProvider>)?.zitadel
} catch (e: any) {
error.value = e?.message || 'Failed to load authentication'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="min-h-screen flex items-center justify-center" style="background: var(--page-bg);">
<div class="w-full max-w-md p-8 rounded-xl border" style="background: var(--surface); border-color: var(--card-border);">
<div class="text-center mb-8">
<h1 class="text-2xl font-semibold" style="color: var(--text-primary);">
Welcome to Segur-OS
</h1>
<p class="mt-2 text-sm" style="color: var(--text-muted);">
Sign in to access your insurance management dashboard
</p>
</div>
<template v-if="error">
<div class="text-sm text-red-500 mb-4">{{ error }}</div>
</template>
<template v-else-if="provider">
<form :action="provider.signinUrl" method="POST" class="space-y-4">
<input type="hidden" name="csrfToken" :value="csrfToken" />
<input type="hidden" name="callbackUrl" :value="callbackUrl" />
<input type="hidden" name="provider" value="zitadel" />
<button
type="submit"
class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background: var(--brand); color: white;"
>
<UIcon name="i-heroicons-lock-closed" class="h-5 w-5" />
<span>Sign in with CorredorConect ID</span>
</button>
</form>
</template>
<template v-else-if="loading">
<button
type="button"
disabled
class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg font-medium"
style="background: var(--brand); color: white; opacity: 0.5;"
>
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin" />
<span>Loading...</span>
</button>
</template>
<div class="mt-6 text-center text-xs" style="color: var(--text-muted);">
<p>Secure authentication powered by CorredorConect ID</p>
</div>
</div>
</div>
</template>

View File

@@ -26,7 +26,7 @@ function statusColor(status: string) {
switch (status) {
case 'quote_requested': return 'yellow'
case 'quotes_received': return 'blue'
case 'solicitation_sent': return 'purple'
case 'awaiting_policy': return 'purple'
case 'issued': return 'green'
default: return 'gray'
}
@@ -36,7 +36,7 @@ function statusLabel(status: string) {
switch (status) {
case 'quote_requested': return 'Quote Requested'
case 'quotes_received': return 'Quotes Received'
case 'solicitation_sent': return 'Solicitation Sent'
case 'awaiting_policy': return 'Awaiting Policy'
case 'issued': return 'Issued'
default: return status
}
@@ -57,7 +57,7 @@ function policyApplicantDoc(p: any) {
}
function policyDetailsSummary(p: any) {
const d = p.policy_details
const d = p.insured_object
if (!d || typeof d !== 'object') return '—'
if (p.policy_type === 'car') {
const parts = [d.year, d.make, d.model].filter((x: any) => x != null && String(x) !== '')

View File

@@ -36,7 +36,7 @@ function policyApplicantName(p: any) {
}
function policyDetailsSummary(p: any) {
const d = p.policy_details
const d = p.insured_object
if (!d || typeof d !== 'object') return '—'
if (p.policy_type === 'car') {
const parts = [d.year, d.make, d.model].filter((x: any) => x !== undefined && x !== null && String(x) !== '')
@@ -55,7 +55,7 @@ function statusColor(status: string) {
switch (status) {
case 'quote_requested': return 'yellow'
case 'quotes_received': return 'blue'
case 'solicitation_sent': return 'purple'
case 'awaiting_policy': return 'purple'
case 'issued': return 'green'
default: return 'gray'
}
@@ -65,7 +65,7 @@ function statusLabel(status: string) {
switch (status) {
case 'quote_requested': return 'Quote Requested'
case 'quotes_received': return 'Quotes Received'
case 'solicitation_sent': return 'Solicitation Sent'
case 'awaiting_policy': return 'Awaiting Policy'
case 'issued': return 'Issued'
default: return status
}

View File

@@ -29,7 +29,7 @@ function policyApplicantName(p: any) {
}
function policyDetailsSummary(p: any) {
const d = p.policy_details
const d = p.insured_object
if (!d || typeof d !== 'object') return '—'
if (p.policy_type === 'car') {
const parts = [d.year, d.make, d.model].filter((x: any) => x !== undefined && x !== null && String(x) !== '')
@@ -48,7 +48,7 @@ function statusColor(status: string) {
switch (status) {
case 'quote_requested': return 'yellow'
case 'quotes_received': return 'blue'
case 'solicitation_sent': return 'purple'
case 'awaiting_policy': return 'purple'
case 'issued': return 'green'
default: return 'gray'
}
@@ -58,7 +58,7 @@ function statusLabel(status: string) {
switch (status) {
case 'quote_requested': return 'Quote Requested'
case 'quotes_received': return 'Quotes Received'
case 'solicitation_sent': return 'Solicitation Sent'
case 'awaiting_policy': return 'Awaiting Policy'
case 'issued': return 'Issued'
default: return status
}

View File

@@ -11,7 +11,7 @@ const statusItems = [
{ label: 'All Statuses', value: null },
{ label: 'Quote Requested', value: 'quote_requested' },
{ label: 'Quotes Received', value: 'quotes_received' },
{ label: 'Solicitation Sent', value: 'solicitation_sent' },
{ label: 'Awaiting Policy', value: 'awaiting_policy' },
{ label: 'Issued', value: 'issued' }
]
@@ -45,6 +45,11 @@ const { data, pending, refresh } = usePolicy('/policies', {
})
})
// Ensure data is refreshed when page loads
onMounted(() => {
refresh()
})
const policies = computed(() => data.value?.data ?? [])
const meta = computed(() => data.value?.meta)
@@ -67,7 +72,7 @@ function policyApplicantDoc(p: any) {
}
function policyDetailsSummary(p: any) {
const d = p.policy_details
const d = p.insured_object
if (!d || typeof d !== 'object') return '—'
if (p.policy_type === 'car') {
const parts = [d.year, d.make, d.model].filter((x: any) => x !== undefined && x !== null && String(x) !== '')
@@ -86,7 +91,7 @@ function statusColor(status: string) {
switch (status) {
case 'quote_requested': return 'yellow'
case 'quotes_received': return 'blue'
case 'solicitation_sent': return 'purple'
case 'awaiting_policy': return 'purple'
case 'issued': return 'green'
default: return 'gray'
}
@@ -96,7 +101,7 @@ function statusLabel(status: string) {
switch (status) {
case 'quote_requested': return 'Quote Requested'
case 'quotes_received': return 'Quotes Received'
case 'solicitation_sent': return 'Solicitation Sent'
case 'awaiting_policy': return 'Awaiting Policy'
case 'issued': return 'Issued'
default: return status
}

View File

@@ -3,75 +3,62 @@ import type { SelectItem } from '@nuxt/ui'
import { refDebounced } from '@vueuse/core'
const router = useRouter()
const policyType = ref<'car' | 'life' | 'fire'>('car')
const policyType = ref<'car' | 'life' | 'fire_structure' | 'fire_contents'>('car')
const submitting = ref(false)
const toast = useToast()
const { $policy } = useNuxtApp()
// ---------------------------------------------------------------------------
// Customer selection
// Buyer and Insured Information
// ---------------------------------------------------------------------------
const customerSearch = ref('')
const debouncedCustomerSearch = refDebounced(customerSearch, 300)
const customerPage = ref(1)
const selectedCustomer = ref<any>(null)
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
query: computed(() => ({
'page_size': 12,
'page': customerPage.value,
...(debouncedCustomerSearch.value && {
'filters[0][field]': 'search',
'filters[0][op]': '==',
'filters[0][value]': debouncedCustomerSearch.value
})
}))
const buyerInfo = ref({
type: 'individual' as 'individual' | 'corporate',
name: '',
date_of_birth: '',
document_id: '',
email: '',
phone: '',
address: '',
company_name: '',
ruc: '',
legal_rep_name: '',
legal_rep_document: ''
})
watch(debouncedCustomerSearch, () => { customerPage.value = 1 })
const insuredInfo = ref({
type: 'individual' as 'individual' | 'corporate',
name: '',
date_of_birth: '',
document_id: '',
gender: 'male' as 'male' | 'female',
email: '',
phone: '',
address: '',
company_name: '',
ruc: '',
legal_rep_name: '',
legal_rep_document: ''
})
const customerItems = computed(() => customersData.value?.data ?? [])
const customerMeta = computed(() => customersData.value?.meta)
function selectCustomer(customer: any) {
selectedCustomer.value = customer
function copyBuyerToInsured() {
insuredInfo.value = { ...buyerInfo.value }
}
const customerDisplayName = (c: any) =>
c.customer_type === 'corporate'
? (c.commercial_name || c.legal_name)
: `${c.first_name} ${c.last_name}`
const customerSubtitle = (c: any) =>
c.customer_type === 'corporate' ? c.ruc : c.email
// Build applicant_info from selected customer — shape varies by type
const applicantInfo = computed(() => {
const c = selectedCustomer.value
if (!c) return null
if (c.customer_type === 'corporate') {
return {
company_name: c.legal_name,
ruc: c.ruc,
legal_rep_name: c.legal_rep_name,
legal_rep_document: c.legal_rep_document_id
}
}
return {
name: `${c.first_name} ${c.last_name}`.trim(),
date_of_birth: c.birth_date,
document_id: c.document_id
const isBuyerValid = computed(() => {
const b = buyerInfo.value
if (b.type === 'corporate') {
return !!(b.company_name && b.ruc && b.legal_rep_name && b.legal_rep_document)
}
return !!(b.name && b.date_of_birth && b.document_id)
})
const isApplicantValid = computed(() => {
const c = selectedCustomer.value
if (!c) return false
if (c.customer_type === 'corporate') {
return !!(c.legal_name && c.ruc)
const isInsuredValid = computed(() => {
const i = insuredInfo.value
if (i.type === 'corporate') {
return !!(i.company_name && i.ruc && i.legal_rep_name && i.legal_rep_document)
}
return !!(c.birth_date && c.document_id)
return !!(i.name && i.date_of_birth && i.document_id && i.gender)
})
// ---------------------------------------------------------------------------
@@ -81,7 +68,8 @@ const isApplicantValid = computed(() => {
const policyTypeItems = ref<SelectItem[]>([
{ label: 'Car Insurance', value: 'car' },
{ label: 'Life Insurance', value: 'life', disabled: true },
{ label: 'Fire Insurance', value: 'fire', disabled: true }
{ label: 'Fire Structure', value: 'fire_structure', disabled: true },
{ label: 'Fire Contents', value: 'fire_contents', disabled: true }
])
// ---------------------------------------------------------------------------
@@ -94,11 +82,16 @@ const carForm = ref({
make: '',
model: '',
year: new Date().getFullYear(),
car_value: '',
market_value: '',
requested_value: '',
use_type: 'private',
car_type: 'sedan',
chassis_number: '',
engine_number: ''
engine_number: '',
rc_limits: {
bodily_injury: 0,
property_damage: 0
}
},
selected_providers: [] as { provider_id: string, email: string }[]
})
@@ -169,14 +162,15 @@ async function submitCarPolicy() {
method: 'POST',
body: {
policy_type: 'car',
applicant_info: applicantInfo.value,
policy_details: carForm.value.car_details,
buyer: buyerInfo.value,
insured: insuredInfo.value,
insured_object: carForm.value.car_details,
selected_providers: carForm.value.selected_providers
}
}) as any
toast.add({ title: 'Policy submitted successfully', color: 'green' })
router.push(`/policies/app/${data.application_id}`)
router.push(`/policies/${data.application_id}`)
} catch (e: any) {
toast.add({
title: 'Failed to submit policy',
@@ -191,14 +185,15 @@ async function submitCarPolicy() {
const isCarFormValid = computed(() => {
const { car_details, selected_providers } = carForm.value
return (
isApplicantValid.value &&
isBuyerValid.value &&
isInsuredValid.value &&
car_details.plate &&
car_details.make &&
car_details.model &&
car_details.year &&
car_details.car_value &&
car_details.chassis_number &&
car_details.engine_number &&
car_details.market_value &&
car_details.requested_value &&
car_details.rc_limits &&
selected_providers.length > 0
)
})
@@ -219,113 +214,145 @@ const isCarFormValid = computed(() => {
</div>
</div>
<!-- Customer Selection -->
<!-- Buyer Information -->
<UCard>
<template #header>
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-users" class="w-4 h-4" />
Select Customer
<UIcon name="i-heroicons-user" class="w-4 h-4" />
Buyer Information
</p>
</template>
<div class="space-y-4">
<UInput
v-model="customerSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Search by name, email, RUC..."
class="w-full max-w-sm"
/>
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
<div class="flex gap-4 mb-4">
<UFormField label="Type" required class="flex-1">
<USelect v-model="buyerInfo.type" :items="[{label: 'Individual', value: 'individual'}, {label: 'Corporate', value: 'corporate'}]" class="w-full" />
</UFormField>
</div>
<div v-else class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-72 overflow-y-auto pr-1">
<div
v-for="c in customerItems"
:key="c.id"
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
:class="selectedCustomer?.id === c.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300 bg-[var(--surface)]'"
@click="selectCustomer(c)"
>
<UAvatar :alt="customerDisplayName(c)" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium text-sm text-[var(--text-primary)] truncate">{{ customerDisplayName(c) }}</p>
<UBadge
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
variant="soft" size="xs" class="flex-shrink-0"
>
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
</UBadge>
</div>
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
</div>
<UIcon
v-if="selectedCustomer?.id === c.id"
name="i-heroicons-check-circle"
class="w-5 h-5 text-primary-500 flex-shrink-0"
/>
</div>
<div v-if="customerItems.length === 0" class="col-span-3 text-center py-6 text-gray-400 text-sm">
No customers found.
<NuxtLink to="/customers/new" class="text-primary-500 underline ml-1">Create one</NuxtLink>
</div>
<template v-if="buyerInfo.type === 'individual'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Full Name" required class="col-span-2">
<UInput v-model="buyerInfo.name" placeholder="Juan Pérez" class="w-full" />
</UFormField>
<UFormField label="Date of Birth" required>
<UInput v-model="buyerInfo.date_of_birth" type="date" class="w-full" />
</UFormField>
<UFormField label="Document ID" required>
<UInput v-model="buyerInfo.document_id" placeholder="8-123-456" class="w-full" />
</UFormField>
<UFormField label="Email">
<UInput v-model="buyerInfo.email" type="email" placeholder="juan@example.com" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="buyerInfo.phone" placeholder="+507-1234-5678" class="w-full" />
</UFormField>
<UFormField label="Address" class="col-span-2">
<UInput v-model="buyerInfo.address" placeholder="Calle 50, Panama City" class="w-full" />
</UFormField>
</div>
</template>
<div v-if="customerMeta && customerMeta.total_pages > 1" class="flex justify-between items-center text-sm text-gray-500">
<span>{{ customerMeta.total_count }} customers</span>
<UPagination
v-model="customerPage"
:total="customerMeta.total_count"
:page-count="customerMeta.page_size"
size="sm"
/>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Company Name" required class="col-span-2">
<UInput v-model="buyerInfo.company_name" placeholder="Empresa XYZ S.A." class="w-full" />
</UFormField>
<UFormField label="RUC" required>
<UInput v-model="buyerInfo.ruc" placeholder="987654-1-987654" class="w-full" />
</UFormField>
<UFormField label="Legal Representative Name" required>
<UInput v-model="buyerInfo.legal_rep_name" placeholder="Carlos López" class="w-full" />
</UFormField>
<UFormField label="Legal Representative Document" required>
<UInput v-model="buyerInfo.legal_rep_document" placeholder="8-789-012" class="w-full" />
</UFormField>
<UFormField label="Email">
<UInput v-model="buyerInfo.email" type="email" placeholder="carlos@empresa-xyz.com" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="buyerInfo.phone" placeholder="+507-8765-4321" class="w-full" />
</UFormField>
<UFormField label="Address" class="col-span-2">
<UInput v-model="buyerInfo.address" placeholder="Calle 100, Panama City" class="w-full" />
</UFormField>
</div>
</template>
</div>
</UCard>
<!-- Insured Information -->
<UCard>
<template #header>
<div class="flex justify-between items-center">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-shield-check" class="w-4 h-4" />
Insured Information
</p>
<UButton size="xs" color="gray" variant="soft" @click="copyBuyerToInsured">
Copy from Buyer
</UButton>
</div>
</template>
<div class="space-y-4">
<div class="flex gap-4 mb-4">
<UFormField label="Type" required class="flex-1">
<USelect v-model="insuredInfo.type" :items="[{label: 'Individual', value: 'individual'}, {label: 'Corporate', value: 'corporate'}]" class="w-full" />
</UFormField>
</div>
<!-- Selected summary -->
<div
v-if="selectedCustomer"
class="flex items-center gap-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm"
>
<UAvatar :alt="customerDisplayName(selectedCustomer)" size="sm" />
<div class="flex-1">
<div class="flex items-center gap-2">
<p class="font-medium text-primary-800">{{ customerDisplayName(selectedCustomer) }}</p>
<UBadge
:color="selectedCustomer.customer_type === 'corporate' ? 'purple' : 'blue'"
variant="soft" size="xs"
>
{{ selectedCustomer.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
</UBadge>
</div>
<p class="text-primary-600 text-xs">{{ selectedCustomer.email }}</p>
<template v-if="insuredInfo.type === 'individual'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Full Name" required class="col-span-2">
<UInput v-model="insuredInfo.name" placeholder="María García" class="w-full" />
</UFormField>
<UFormField label="Date of Birth" required>
<UInput v-model="insuredInfo.date_of_birth" type="date" class="w-full" />
</UFormField>
<UFormField label="Document ID" required>
<UInput v-model="insuredInfo.document_id" placeholder="8-456-789" class="w-full" />
</UFormField>
<UFormField label="Gender" required>
<USelect v-model="insuredInfo.gender" :items="[{label: 'Male', value: 'male'}, {label: 'Female', value: 'female'}]" class="w-full" />
</UFormField>
<UFormField label="Email">
<UInput v-model="insuredInfo.email" type="email" placeholder="maria@example.com" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="insuredInfo.phone" placeholder="+507-8765-4321" class="w-full" />
</UFormField>
<UFormField label="Address" class="col-span-2">
<UInput v-model="insuredInfo.address" placeholder="Calle 75, Panama City" class="w-full" />
</UFormField>
</div>
<div class="text-xs text-primary-600 text-right space-y-0.5">
<template v-if="selectedCustomer.customer_type === 'corporate'">
<p>RUC: {{ selectedCustomer.ruc ?? '—' }}</p>
<p>Rep: {{ selectedCustomer.legal_rep_name ?? '—' }}</p>
</template>
<template v-else>
<p>DOB: {{ selectedCustomer.birth_date ?? '—' }}</p>
<p>Doc: {{ selectedCustomer.document_id ?? '—' }}</p>
</template>
</div>
<UButton size="xs" color="gray" variant="ghost" @click="selectedCustomer = null">Change</UButton>
</div>
</template>
<!-- Warn if individual customer missing required fields -->
<UAlert
v-if="selectedCustomer && selectedCustomer.customer_type !== 'corporate' && (!selectedCustomer.birth_date || !selectedCustomer.document_id)"
color="yellow" variant="soft" icon="i-heroicons-exclamation-triangle"
title="Incomplete customer record"
description="This customer is missing date of birth or document ID. Please update their record before submitting."
/>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Company Name" required class="col-span-2">
<UInput v-model="insuredInfo.company_name" placeholder="Empresa ABC S.A." class="w-full" />
</UFormField>
<UFormField label="RUC" required>
<UInput v-model="insuredInfo.ruc" placeholder="123456-1-123456" class="w-full" />
</UFormField>
<UFormField label="Legal Representative Name" required>
<UInput v-model="insuredInfo.legal_rep_name" placeholder="María García" class="w-full" />
</UFormField>
<UFormField label="Legal Representative Document" required>
<UInput v-model="insuredInfo.legal_rep_document" placeholder="8-456-789" class="w-full" />
</UFormField>
<UFormField label="Email">
<UInput v-model="insuredInfo.email" type="email" placeholder="contact@empresa-abc.com" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="insuredInfo.phone" placeholder="+507-1234-5678" class="w-full" />
</UFormField>
<UFormField label="Address" class="col-span-2">
<UInput v-model="insuredInfo.address" placeholder="Calle 50, Panama City" class="w-full" />
</UFormField>
</div>
</template>
</div>
</UCard>
@@ -389,8 +416,11 @@ const isCarFormValid = computed(() => {
class="w-full"
/>
</UFormField>
<UFormField label="Car Value (USD)" required>
<UInput v-model="carForm.car_details.car_value" type="number" placeholder="18000" class="w-full" />
<UFormField label="Market Value (USD)" required>
<UInput v-model="carForm.car_details.market_value" type="number" placeholder="18000" class="w-full" />
</UFormField>
<UFormField label="Requested Value (USD)" required>
<UInput v-model="carForm.car_details.requested_value" type="number" placeholder="20000" class="w-full" />
</UFormField>
<UFormField label="Use Type" required>
<USelect v-model="carForm.car_details.use_type" :items="useTypeItems" class="w-full" />
@@ -398,13 +428,26 @@ const isCarFormValid = computed(() => {
<UFormField label="Car Type" required>
<USelect v-model="carForm.car_details.car_type" :items="carTypeItems" class="w-full" />
</UFormField>
<UFormField label="Chassis Number" required>
<UFormField label="Chassis Number">
<UInput v-model="carForm.car_details.chassis_number" placeholder="9BWZZZ377VT004251" class="w-full" />
</UFormField>
<UFormField label="Engine Number" required>
<UFormField label="Engine Number">
<UInput v-model="carForm.car_details.engine_number" placeholder="1NZ-FE-1234567" class="w-full" />
</UFormField>
</div>
<!-- RC Limits -->
<div class="mt-6 pt-6 border-t">
<p class="font-medium text-sm text-[var(--text-primary)] mb-4">RC Limits</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Bodily Injury Limit (USD)" required>
<UInput v-model.number="carForm.car_details.rc_limits.bodily_injury" type="number" placeholder="50000" class="w-full" />
</UFormField>
<UFormField label="Property Damage Limit (USD)" required>
<UInput v-model.number="carForm.car_details.rc_limits.property_damage" type="number" placeholder="25000" class="w-full" />
</UFormField>
</div>
</div>
</UCard>
<!-- Provider Selection -->
@@ -500,3 +543,58 @@ const isCarFormValid = computed(() => {
</template>
</div>
</template>
<style scoped>
/* Prevent width expansion when dropdowns open */
:deep(.grid) {
min-width: 0;
width: 100%;
}
:deep(.UFormField) {
min-width: 0;
width: 100%;
}
:deep(.UInput) {
min-width: 0;
width: 100%;
}
:deep(.USelect) {
min-width: 0;
width: 100%;
}
/* Prevent dropdown menus from expanding container */
:deep([role="listbox"]) {
position: fixed !important;
z-index: 50;
min-width: 200px;
max-width: 300px;
}
/* Ensure form fields don't cause expansion */
:deep(.space-y-4) {
min-width: 0;
width: 100%;
overflow: hidden;
}
/* Prevent any element from expanding beyond container */
:deep(*) {
max-width: 100%;
}
/* Specific fix for dropdown positioning */
:deep(.USelectMenuPopover) {
position: fixed !important;
z-index: 100;
}
/* Ensure form stability */
:deep(.UCard) {
width: 100%;
min-width: 0;
}
</style>

View File

@@ -276,7 +276,7 @@ async function submitQuote() {
legal_rep_document: form.customerSelection.selectedBuyer.legal_rep_document_id
})
},
policy_details: getPolicyDetails(form),
insured_object: getPolicyDetails(form),
selected_providers: form.selectedProviders.map(id => ({
provider_id: id,
email: ''
@@ -353,7 +353,7 @@ function getPolicyDetails(form: any) {
</script>
<template>
<div class="max-w-4xl mx-auto">
<div class="max-w-4xl mx-auto quote-form-container">
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-lg bg-[var(--brand)] flex items-center justify-center">
@@ -386,14 +386,14 @@ function getPolicyDetails(form: any) {
icon: 'i-heroicons-truck',
content: '',
value: 'vehicle',
defaultOpen: true
defaultOpen: false
},
{
label: 'Provider Selection',
icon: 'i-heroicons-building-office',
content: '',
value: 'provider',
defaultOpen: true
defaultOpen: false
}
]">
<template #body="{ item }">
@@ -489,14 +489,14 @@ function getPolicyDetails(form: any) {
icon: 'i-heroicons-shield-check',
content: '',
value: 'life',
defaultOpen: true
defaultOpen: false
},
{
label: 'Provider Selection',
icon: 'i-heroicons-building-office',
content: '',
value: 'provider',
defaultOpen: true
defaultOpen: false
}
]">
<template #body="{ item }">
@@ -573,14 +573,14 @@ function getPolicyDetails(form: any) {
icon: 'i-heroicons-building-office-2',
content: '',
value: 'property',
defaultOpen: true
defaultOpen: false
},
{
label: 'Provider Selection',
icon: 'i-heroicons-building-office',
content: '',
value: 'provider',
defaultOpen: true
defaultOpen: false
}
]">
<template #body="{ item }">
@@ -642,14 +642,14 @@ function getPolicyDetails(form: any) {
icon: 'i-heroicons-building-office-2',
content: '',
value: 'contents',
defaultOpen: true
defaultOpen: false
},
{
label: 'Provider Selection',
icon: 'i-heroicons-building-office',
content: '',
value: 'provider',
defaultOpen: true
defaultOpen: false
}
]">
<template #body="{ item }">
@@ -698,3 +698,87 @@ function getPolicyDetails(form: any) {
</div>
</div>
</template>
<style scoped>
/* Main container width stability */
.quote-form-container {
width: 100%;
max-width: 56rem; /* max-w-4xl equivalent */
margin: 0 auto;
}
/* Prevent width expansion when dropdowns open */
:deep(.grid) {
min-width: 0;
width: 100%;
}
:deep(.UFormField) {
min-width: 0;
width: 100%;
}
:deep(.UInput) {
min-width: 0;
width: 100%;
}
:deep(.USelectMenu) {
min-width: 0;
width: 100%;
}
/* Ensure accordion content maintains width */
:deep(.UAccordion) {
width: 100%;
min-width: 0;
}
:deep([data-headlessui-state]) {
min-width: 0;
}
/* Prevent dropdown menus from expanding container */
:deep([role="listbox"]) {
position: fixed !important;
z-index: 50;
min-width: 200px;
max-width: 300px;
}
/* Ensure form fields don't cause expansion */
:deep(.p-5) {
min-width: 0;
width: 100%;
overflow: hidden;
}
/* Fix accordion content width */
:deep(.UAccordionItem) {
width: 100%;
min-width: 0;
}
:deep(.UAccordionContent) {
width: 100%;
min-width: 0;
overflow: hidden;
}
/* Prevent any element from expanding beyond container */
:deep(*) {
max-width: 100%;
}
/* Specific fix for dropdown positioning */
:deep(.USelectMenuPopover) {
position: fixed !important;
z-index: 100;
}
/* Ensure form stability */
:deep(.UForm) {
width: 100%;
min-width: 0;
}
</style>

View File

@@ -2,8 +2,6 @@
definePageMeta({ ssr: false })
usePageTitle('Leads Hub')
const sidebarFeatures = useSidebarFeatures()
/* ── Types ── */
type LeadSource = 'walk_in' | 'instagram' | 'facebook' | 'google' | 'referral' | 'website' | 'phone' | 'campaign'

View File

@@ -9,62 +9,91 @@ interface QuickLead {
name: string
phone: string
email: string
product: string
source: string
priority: 'normal' | 'high' | 'urgent'
note: string
agent: string
createdAt: string
}
/* ── Storage ── */
const STORAGE_KEY = 'policy-ui.quick-leads'
function loadLeads(): QuickLead[] {
if (import.meta.client) {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
return JSON.parse(raw) as QuickLead[]
}
} catch {
return []
}
}
return []
}
function saveLeads(leads: QuickLead[]) {
if (import.meta.client) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(leads))
} catch { /* quota */ }
}
notes: string
priority: 'low' | 'medium' | 'high'
source: 'website' | 'referral' | 'social_media' | 'cold_call' | 'email_campaign' | 'other'
status: 'new' | 'contacted' | 'qualified' | 'proposal' | 'negotiation' | 'converted' | 'lost'
assigned_to?: string
company_name?: string
estimated_value?: string
expected_close_date?: string
inserted_at: string
updated_at: string
status_history?: any[]
}
/* ── State ── */
const leads = ref<QuickLead[]>(loadLeads())
const page = ref(1)
const pageSize = ref(100)
watch(leads, (v) => {
saveLeads(v)
}, { deep: true })
/* ── List filtering ── */
type ListFilter = 'all' | 'high' | 'medium' | 'low'
const activeFilter = ref<ListFilter>('all')
function addLead(lead: Omit<QuickLead, 'id' | 'createdAt'>) {
const newLead: QuickLead = {
id: crypto.randomUUID?.() ?? String(Date.now() + Math.random()),
createdAt: new Date().toISOString(),
...lead,
// Fetch leads from API
const { data: leadsData, pending: leadsPending, refresh: refreshLeads } = useCustomer('/leads', {
query: computed(() => {
const filters: Record<string, string> = {}
let i = 0
if (activeFilter.value !== 'all') {
filters[`filters[${i}][field]`] = 'priority'
filters[`filters[${i}][op]`] = '=='
filters[`filters[${i}][value]`] = activeFilter.value
i++
}
return {
page: page.value,
page_size: pageSize.value,
...filters
}
})
})
const leads = computed(() => leadsData.value?.data ?? [])
const meta = computed(() => leadsData.value?.meta)
// Create lead function
async function createLead(leadData: Partial<QuickLead>) {
try {
await $fetch('/api/v1/leads', {
baseURL: 'https://dev.api.corredorconect.com/customer',
method: 'POST',
body: {
name: leadData.name,
phone: leadData.phone,
email: leadData.email,
notes: leadData.notes,
priority: leadData.priority,
source: leadData.source,
status: 'new',
assigned_to: leadData.assigned_to,
company_name: leadData.company_name,
estimated_value: leadData.estimated_value,
expected_close_date: leadData.expected_close_date
}
})
await refreshLeads()
} catch (error) {
console.error('Failed to create lead:', error)
throw error
}
leads.value = [newLead, ...leads.value]
}
function removeLead(id: string) {
leads.value = leads.value.filter(l => l.id !== id)
}
function recentLeads(days: number): QuickLead[] {
const cutoff = Date.now() - days * 86400000
return leads.value.filter(l => new Date(l.createdAt).getTime() > cutoff)
// Update lead status function
async function updateLeadStatus(id: string, status: QuickLead['status']) {
try {
await $fetch(`/api/v1/leads/${id}/status`, {
baseURL: 'https://dev.api.corredorconect.com/customer',
method: 'PUT',
body: { status }
})
await refreshLeads()
} catch (error) {
console.error('Failed to update lead status:', error)
throw error
}
}
/* ── Form state ── */
@@ -72,69 +101,109 @@ const formOpen = ref(false)
const name = ref('')
const phone = ref('')
const email = ref('')
const product = ref('')
const notes = ref('')
const priority = ref<'low' | 'medium' | 'high'>('low')
const source = ref('')
const priority = ref<'normal' | 'high' | 'urgent'>('normal')
const note = ref('')
const productOptions = [
{ label: 'Auto', value: 'Auto' },
{ label: 'Health', value: 'Health' },
{ label: 'Life', value: 'Life' },
{ label: 'General Risk', value: 'General Risk' },
{ label: 'Custom', value: 'Custom' },
]
const assigned_to = ref('')
const company_name = ref('')
const estimated_value = ref('')
const expected_close_date = ref('')
const sourceOptions = [
{ label: 'Walk-in', value: 'walk-in' },
{ label: 'Referral', value: 'referral' },
{ label: 'Phone call', value: 'phone' },
{ label: 'Website', value: 'website' },
{ label: 'Social media', value: 'social' },
{ label: 'Referral', value: 'referral' },
{ label: 'Social media', value: 'social_media' },
{ label: 'Cold call', value: 'cold_call' },
{ label: 'Email campaign', value: 'email_campaign' },
{ label: 'Other', value: 'other' },
]
const priorityOptions = [
{ label: 'Normal', value: 'normal' as const },
{ label: 'High — follow up today', value: 'high' as const },
{ label: 'Urgent — client waiting', value: 'urgent' as const },
{ label: 'Low', value: 'low' as const },
{ label: 'Medium', value: 'medium' as const },
{ label: 'High', value: 'high' as const },
]
function submit() {
if (!name.value.trim() || !product.value) return
addLead({
name: name.value.trim(),
phone: phone.value.trim(),
email: email.value.trim(),
product: product.value,
source: source.value || 'other',
priority: priority.value,
note: note.value.trim(),
agent: 'Me', // placeholder
})
toast.add({ title: 'Lead captured', description: `${name.value} added to quick leads`, color: 'success' })
resetForm()
const statusOptions = [
{ label: 'New', value: 'new' },
{ label: 'Contacted', value: 'contacted' },
{ label: 'Qualified', value: 'qualified' },
{ label: 'Proposal', value: 'proposal' },
{ label: 'Negotiation', value: 'negotiation' },
{ label: 'Converted', value: 'converted' },
{ label: 'Lost', value: 'lost' },
]
async function submit() {
if (!name.value.trim()) return
try {
await createLead({
name: name.value.trim(),
phone: phone.value.trim(),
email: email.value.trim(),
notes: notes.value.trim(),
priority: priority.value,
source: source.value || 'other',
assigned_to: assigned_to.value.trim() || undefined,
company_name: company_name.value.trim() || undefined,
estimated_value: estimated_value.value.trim() || undefined,
expected_close_date: expected_close_date.value || undefined,
})
toast.add({ title: 'Lead captured', description: `${name.value} added to quick leads`, color: 'success' })
resetForm()
} catch (error) {
toast.add({ title: 'Failed to create lead', description: 'Please try again', color: 'error' })
}
}
function resetForm() {
name.value = ''
phone.value = ''
email.value = ''
product.value = ''
notes.value = ''
priority.value = 'low'
source.value = ''
priority.value = 'normal'
note.value = ''
assigned_to.value = ''
company_name.value = ''
estimated_value.value = ''
expected_close_date.value = ''
formOpen.value = false
}
function confirmRemove(id: string) {
removeLead(id)
toast.add({ title: 'Lead removed', color: 'neutral' })
async function handleStatusChange(leadId: string, newStatus: QuickLead['status']) {
try {
await updateLeadStatus(leadId, newStatus)
toast.add({ title: 'Status updated', description: `Lead status changed to ${newStatus}`, color: 'success' })
} catch (error) {
toast.add({ title: 'Failed to update status', description: 'Please try again', color: 'error' })
}
}
/* ── List filtering ── */
type ListFilter = 'all' | 'urgent' | 'high' | 'normal'
const activeFilter = ref<ListFilter>('all')
/* ── Drag and drop for status ── */
const draggedLead = ref<string | null>(null)
const draggedOverStatus = ref<QuickLead['status'] | null>(null)
function handleDragStart(leadId: string) {
draggedLead.value = leadId
}
function handleDragOver(status: QuickLead['status']) {
draggedOverStatus.value = status
}
function handleDragEnd() {
if (draggedLead.value && draggedOverStatus.value) {
handleStatusChange(draggedLead.value, draggedOverStatus.value)
}
draggedLead.value = null
draggedOverStatus.value = null
}
function handleDrop(leadId: string, newStatus: QuickLead['status']) {
handleStatusChange(leadId, newStatus)
}
const filteredLeads = computed(() => {
if (activeFilter.value === 'all') return leads.value
@@ -143,16 +212,34 @@ const filteredLeads = computed(() => {
const filterCounts = computed(() => ({
all: leads.value.length,
urgent: leads.value.filter(l => l.priority === 'urgent').length,
high: leads.value.filter(l => l.priority === 'high').length,
normal: leads.value.filter(l => l.priority === 'normal').length,
medium: leads.value.filter(l => l.priority === 'medium').length,
low: leads.value.filter(l => l.priority === 'low').length,
}))
const recentLeads = computed(() => {
const cutoff = Date.now() - 10 * 86400000
return leads.value.filter(l => new Date(l.inserted_at).getTime() > cutoff)
})
/* ── Helpers ── */
function priorityMeta(p: string) {
if (p === 'urgent') return { label: 'Urgent', class: 'ql-pri-urgent' }
if (p === 'high') return { label: 'High', class: 'ql-pri-high' }
return { label: 'Normal', class: 'ql-pri-normal' }
if (p === 'high') return { label: 'High', class: 'ql-pri-urgent' }
if (p === 'medium') return { label: 'Medium', class: 'ql-pri-high' }
return { label: 'Low', class: 'ql-pri-normal' }
}
function statusMeta(s: string) {
const statusMap: Record<string, { label: string; class: string }> = {
new: { label: 'New', class: 'ql-status-new' },
contacted: { label: 'Contacted', class: 'ql-status-contacted' },
qualified: { label: 'Qualified', class: 'ql-status-qualified' },
proposal: { label: 'Proposal', class: 'ql-status-proposal' },
negotiation: { label: 'Negotiation', class: 'ql-status-negotiation' },
converted: { label: 'Converted', class: 'ql-status-converted' },
lost: { label: 'Lost', class: 'ql-status-lost' },
}
return statusMap[s] || { label: s, class: 'ql-status-new' }
}
function formatDate(iso: string) {
@@ -228,21 +315,33 @@ function formatDate(iso: string) {
<div class="ql-section">
<p class="ql-section-title">Lead info</p>
<div class="ql-fields">
<div class="ql-field">
<label class="ql-label">Product line <span class="ql-required">*</span></label>
<USelect v-model="product" :items="productOptions" placeholder="Select line..." size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Source</label>
<USelect v-model="source" :items="sourceOptions" placeholder="How did they find us?" size="sm" />
</div>
<div class="ql-field ql-field-full">
<div class="ql-field">
<label class="ql-label">Priority</label>
<USelect v-model="priority" :items="priorityOptions" placeholder="Normal" size="sm" />
<USelect v-model="priority" :items="priorityOptions" placeholder="Low" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Assigned to</label>
<UInput v-model="assigned_to" placeholder="Agent name" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Company name</label>
<UInput v-model="company_name" placeholder="Company (optional)" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Estimated value</label>
<UInput v-model="estimated_value" placeholder="$0.00" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Expected close date</label>
<UInput v-model="expected_close_date" type="date" size="sm" />
</div>
<div class="ql-field ql-field-full">
<label class="ql-label">Notes</label>
<UTextarea v-model="note" placeholder="Brief context, referral source, or anything useful for follow-up..." size="sm" :rows="2" />
<UTextarea v-model="notes" placeholder="Brief context, referral source, or anything useful for follow-up..." size="sm" :rows="2" />
</div>
</div>
</div>
@@ -255,7 +354,7 @@ function formatDate(iso: string) {
</div>
<div class="flex gap-2">
<button type="button" class="ql-cancel-btn" @click="resetForm">Cancel</button>
<button type="button" class="ql-submit-btn" :class="!name.trim() || !product ? 'ql-btn-disabled' : ''" @click="submit">
<button type="button" class="ql-submit-btn" :class="!name.trim() ? 'ql-btn-disabled' : ''" @click="submit">
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
Add Lead
</button>
@@ -272,15 +371,15 @@ function formatDate(iso: string) {
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">Last 10 days</p>
<p class="ql-kpi-value">{{ recentLeads(10).length }}</p>
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">Urgent</p>
<p class="ql-kpi-value" style="color: #c13838;">{{ filterCounts.urgent }}</p>
<p class="ql-kpi-value">{{ recentLeads.length }}</p>
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">High priority</p>
<p class="ql-kpi-value" style="color: #c27b1a;">{{ filterCounts.high }}</p>
<p class="ql-kpi-value" style="color: #c13838;">{{ filterCounts.high }}</p>
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">Medium priority</p>
<p class="ql-kpi-value" style="color: #c27b1a;">{{ filterCounts.medium }}</p>
</div>
</div>
@@ -290,9 +389,9 @@ function formatDate(iso: string) {
<button
v-for="f in ([
{ id: 'all', label: 'All' },
{ id: 'urgent', label: 'Urgent' },
{ id: 'high', label: 'High' },
{ id: 'normal', label: 'Normal' },
{ id: 'medium', label: 'Medium' },
{ id: 'low', label: 'Low' },
] as { id: ListFilter; label: string }[])"
:key="f.id"
type="button"
@@ -308,7 +407,12 @@ function formatDate(iso: string) {
</div>
<!-- ═══ Leads list ═══ -->
<div v-if="filteredLeads.length === 0" class="ql-empty">
<div v-if="leadsPending" class="ql-empty">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin" style="color: #c0c0bc;" />
<p class="text-[13px] text-[var(--text-muted)] mt-2">Loading leads...</p>
</div>
<div v-else-if="filteredLeads.length === 0" class="ql-empty">
<UIcon name="i-heroicons-bolt" style="width: 32px; height: 32px; color: #c0c0bc;" />
<p class="text-[13px] text-[var(--text-muted)] mt-2">No quick leads yet.</p>
<button type="button" class="ql-add-btn mt-3" @click="formOpen = true">
@@ -333,32 +437,47 @@ function formatDate(iso: string) {
<div class="flex items-center gap-2 flex-wrap">
<p class="text-[14px] font-semibold text-[var(--text-primary)] truncate">{{ lead.name }}</p>
<span :class="priorityMeta(lead.priority).class">{{ priorityMeta(lead.priority).label }}</span>
<span class="ql-product-tag">{{ lead.product }}</span>
<div
:class="statusMeta(lead.status).class"
class="cursor-pointer hover:opacity-80"
draggable="true"
@dragstart="handleDragStart(lead.id)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver(lead.status)"
@drop="handleDrop(lead.id, lead.status)"
title="Drag to change status"
>
{{ statusMeta(lead.status).label }}
</div>
</div>
<div class="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--text-muted)]">
<span v-if="lead.phone">{{ lead.phone }}</span>
<span v-if="lead.email">{{ lead.email }}</span>
<span>{{ formatDate(lead.createdAt) }}</span>
<span>{{ formatDate(lead.inserted_at) }}</span>
</div>
</div>
<div class="ql-lead-actions">
<UDropdown :items="statusOptions.map(s => ({ label: s.label, click: () => handleStatusChange(lead.id, s.value as QuickLead['status']) }))">
<button type="button" class="ql-action-btn" title="Change status">
<UIcon name="i-heroicons-arrows-pointing-out" style="width: 14px; height: 14px;" />
</button>
</UDropdown>
<NuxtLink :to="`/quotes/new`" title="Start quote">
<button type="button" class="ql-action-btn ql-action-quote">
<UIcon name="i-heroicons-calculator" style="width: 14px; height: 14px;" />
</button>
</NuxtLink>
<button type="button" class="ql-action-btn ql-action-delete" title="Remove" @click="confirmRemove(lead.id)">
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
</button>
</div>
</div>
<div v-if="lead.note" class="ql-lead-note">
<div v-if="lead.notes" class="ql-lead-note">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" style="width: 11px; height: 11px; color: #8a8a86; flex-shrink: 0;" />
<span>{{ lead.note }}</span>
<span>{{ lead.notes }}</span>
</div>
<div class="ql-lead-meta">
<span v-if="lead.source" class="ql-meta-tag">{{ lead.source }}</span>
<span class="ql-meta-tag">{{ lead.agent }}</span>
<span v-if="lead.company_name" class="ql-meta-tag">{{ lead.company_name }}</span>
<span v-if="lead.assigned_to" class="ql-meta-tag">{{ lead.assigned_to }}</span>
<span v-if="lead.estimated_value" class="ql-meta-tag">{{ lead.estimated_value }}</span>
</div>
</div>
</TransitionGroup>
@@ -546,9 +665,35 @@ function formatDate(iso: string) {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap;
}
.ql-product-tag {
/* ── Status badges ── */
.ql-status-new {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(1,105,111,0.07); color: #01696f; white-space: nowrap;
background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap;
}
.ql-status-contacted {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(59,130,246,0.08); color: #3b82f6; white-space: nowrap;
}
.ql-status-qualified {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(16,185,129,0.08); color: #10b981; white-space: nowrap;
}
.ql-status-proposal {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(245,158,11,0.08); color: #f59e0b; white-space: nowrap;
}
.ql-status-negotiation {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(139,92,246,0.08); color: #8b5cf6; white-space: nowrap;
}
.ql-status-converted {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(34,197,94,0.08); color: #22c55e; white-space: nowrap;
}
.ql-status-lost {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(107,114,128,0.08); color: #6b7280; white-space: nowrap;
}
/* ── Empty ── */

View File

@@ -1,304 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const id = route.params.id as string
const { data, pending, error, refresh } = useTasks(`/tasks/${id}`)
const task = computed(() => data.value.task)
const payload = computed(() => data.value?.payload)
const isSolicitation = computed(() => task.value?.comm_type === 'solicitation')
const isQuote = computed(() => task.value?.comm_type === 'quote')
// ── Respond (quote) ──────────────────────────────────────────────────────────
const isRespondOpen = ref(false)
const submitting = ref(false)
const toast = useToast()
const { $tasks } = useNuxtApp()
const plans = ref([{ name: '', premium: '', coverage_details: '', deductible: '', coverage_limit: '' }])
const respondForm = ref({ valid_until: '', entered_by: '' })
function addPlan() { plans.value.push({ name: '', premium: '', coverage_details: '', deductible: '', coverage_limit: '' }) }
function removePlan(i: number) { plans.value.splice(i, 1) }
async function submitResponse() {
submitting.value = true
try {
await $tasks(`/tasks/${id}/respond`, {
method: 'POST',
body: {
valid_until: respondForm.value.valid_until,
entered_by: respondForm.value.entered_by,
plans: plans.value.map(p => ({
name: p.name, premium: parseFloat(p.premium), coverage_details: p.coverage_details,
deductible: p.deductible ? parseFloat(p.deductible) : 0,
coverage_limit: p.coverage_limit ? parseFloat(p.coverage_limit) : 0
}))
}
})
toast.add({ title: 'Response submitted', color: 'green' })
isRespondOpen.value = false
await refresh()
} catch (e: any) {
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
} finally { submitting.value = false }
}
const isRespondFormValid = computed(() =>
respondForm.value.valid_until && respondForm.value.entered_by &&
plans.value.every(p => p.name && p.premium && p.coverage_details)
)
// ── Confirm delivery (solicitation) ─────────────────────────────────────────
const confirmingDelivery = ref(false)
async function confirmDelivery() {
confirmingDelivery.value = true
try {
await $tasks(`/tasks/${id}/confirm-delivery`, { method: 'POST' })
toast.add({ title: 'Delivery confirmed', color: 'green' })
await refresh()
} catch (e: any) {
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
} finally { confirmingDelivery.value = false }
}
// ── Issue policy (solicitation) ──────────────────────────────────────────────
const isIssueOpen = ref(false)
const issuing = ref(false)
const issueForm = ref({ policy_number: '', effective_date: '', expiry_date: '' })
const isIssueFormValid = computed(() =>
issueForm.value.policy_number && issueForm.value.effective_date && issueForm.value.expiry_date
)
async function submitIssuePolicy() {
issuing.value = true
try {
await $tasks(`/tasks/${id}/issue-policy`, { method: 'POST', body: issueForm.value })
toast.add({ title: 'Policy issued successfully', color: 'green' })
isIssueOpen.value = false
await refresh()
} catch (e: any) {
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
} finally { issuing.value = false }
}
// ── Solicitation PDF ─────────────────────────────────────────────────────────
const pdfUrl = computed(() => task.value?.download_url ?? null)
const showPdf = ref(false)
// ── Helpers ──────────────────────────────────────────────────────────────────
const statusColor = (s: string) =>
({ pending: 'yellow', responded: 'blue', delivered: 'purple', issued: 'green' }[s] ?? 'gray')
const formatDate = (d: string) => d
? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: '—'
</script>
<template>
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
<NuxtLink to="/tasks">
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Tasks</UButton>
</NuxtLink>
<UAlert v-if="error" color="red" variant="soft" title="Failed to load task" :description="error.message" />
<div v-else-if="pending" class="space-y-4"><UCard v-for="n in 3" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard></div>
<template v-else-if="task">
<!-- Header -->
<div class="flex justify-between items-start">
<div class="space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<UBadge :color="statusColor(task.status)" variant="soft">{{ task.status }}</UBadge>
<UBadge color="blue" variant="outline">{{ task.policy_type?.toUpperCase() }}</UBadge>
<UBadge color="gray" variant="outline">{{ task.comm_type }}</UBadge>
</div>
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)] font-mono">{{ task.application_id }}</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">Received {{ formatDate(task.created_at) }}</p>
</div>
<!-- Actions -->
<div class="flex gap-2 flex-wrap justify-end">
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
<!-- Quote actions -->
<UButton v-if="isQuote && task.status === 'pending'"
icon="i-heroicons-chat-bubble-left-right" color="primary" @click="isRespondOpen = true">
Record Response
</UButton>
<!-- Solicitation actions -->
<template v-if="isSolicitation">
<UButton v-if="task.status === 'pending'"
icon="i-heroicons-check" color="green" variant="soft"
:loading="confirmingDelivery" @click="confirmDelivery">
Confirm Delivery
</UButton>
<UButton v-if="task.status === 'delivered'"
icon="i-heroicons-document-check" color="primary" @click="isIssueOpen = true">
Issue Policy
</UButton>
</template>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Task info -->
<UCard>
<template #header><p class="font-semibold text-[var(--text-primary)]">Task Info</p></template>
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Task ID</span><span class="font-mono text-xs">{{ task.id }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Application ID</span><span class="font-mono text-xs">{{ task.application_id }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Provider ID</span><span class="font-mono text-xs">{{ task.provider_id }}</span></div>
<div v-if="task.provider_name" class="flex justify-between"><span class="text-gray-500">Provider</span><span>{{ task.provider_name }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Org</span><span class="font-mono text-xs">{{ task.org_id }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Created</span><span>{{ formatDate(task.created_at) }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Updated</span><span>{{ formatDate(task.updated_at) }}</span></div>
</div>
</UCard>
<!-- Payload -->
<UCard>
<template #header><p class="font-semibold text-[var(--text-primary)]">Request Payload</p></template>
<div class="space-y-4 text-sm">
<div v-if="payload?.applicant_info">
<p class="text-xs font-semibold text-gray-400 uppercase mb-2">Applicant</p>
<div class="bg-gray-50 rounded-lg p-3 space-y-1.5">
<div class="flex justify-between"><span class="text-gray-500">Name</span><span>{{ payload.applicant_info.name }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">DOB</span><span>{{ payload.applicant_info.date_of_birth }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Document</span><span class="font-mono text-xs">{{ payload.applicant_info.document_id }}</span></div>
</div>
</div>
<div v-if="payload?.car_details">
<p class="text-xs font-semibold text-gray-400 uppercase mb-2">Vehicle</p>
<div class="bg-gray-50 rounded-lg p-3 space-y-1.5">
<div class="flex justify-between"><span class="text-gray-500">Plate</span><span class="font-mono font-medium">{{ payload.car_details.plate }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Vehicle</span><span>{{ payload.car_details.year }} {{ payload.car_details.make }} {{ payload.car_details.model }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Value</span><span>${{ Number(payload.car_details.car_value).toLocaleString() }}</span></div>
</div>
</div>
</div>
</UCard>
</div>
<!-- Solicitation PDF section -->
<UCard v-if="isSolicitation">
<template #header>
<div class="flex justify-between items-center">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-document-text" class="w-4 h-4" /> Solicitation Document
</p>
<div class="flex gap-2">
<UButton v-if="pdfUrl" icon="i-heroicons-eye" color="gray" variant="soft" size="xs"
@click="showPdf = !showPdf">{{ showPdf ? 'Hide' : 'Preview' }}</UButton>
<UButton v-if="pdfUrl" icon="i-heroicons-arrow-top-right-on-square"
color="gray" variant="soft" size="xs" :to="pdfUrl" target="_blank">Open</UButton>
</div>
</div>
</template>
<div v-if="!pdfUrl" class="text-center py-10 text-gray-400">
<UIcon name="i-heroicons-document" class="w-10 h-10 mx-auto mb-2" />
<p class="text-sm">No solicitation document available</p>
</div>
<div v-else-if="showPdf">
<iframe :src="pdfUrl" class="w-full rounded-lg border" style="height: 600px;" />
</div>
<div v-else class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<UIcon name="i-heroicons-document-text" class="w-8 h-8 text-red-400 flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-[var(--text-primary)]">Solicitation v{{ task.version ?? 1 }}</p>
<p class="text-xs text-gray-400 font-mono truncate">{{ task.s3_key }}</p>
</div>
<UBadge :color="statusColor(task.status)" variant="soft" size="sm">{{ task.status }}</UBadge>
</div>
</UCard>
</template>
<!-- Quote respond slideover -->
<USlideover v-model:open="isRespondOpen" side="right">
<template #content>
<div class="flex flex-col h-full">
<div class="flex justify-between items-center p-6 border-b">
<div><h2 class="text-lg font-semibold text-[var(--text-primary)]">Record Quote Response</h2></div>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isRespondOpen = false" />
</div>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<div class="grid grid-cols-2 gap-4">
<UFormField label="Valid Until" required>
<UInput v-model="respondForm.valid_until" type="date" class="w-full" />
</UFormField>
<UFormField label="Entered By" required>
<UInput v-model="respondForm.entered_by" placeholder="Your name" class="w-full" />
</UFormField>
</div>
<div class="space-y-3">
<div class="flex justify-between items-center">
<p class="font-medium text-sm text-[var(--text-primary)]">Plans <UBadge color="gray" variant="soft" size="xs" class="ml-1">{{ plans.length }}</UBadge></p>
<UButton icon="i-heroicons-plus" size="xs" color="gray" variant="soft" @click="addPlan">Add Plan</UButton>
</div>
<div v-for="(plan, i) in plans" :key="i" class="border rounded-lg p-4 space-y-3">
<div class="flex justify-between items-center">
<p class="text-sm font-semibold text-[var(--text-primary)]">Plan {{ i + 1 }}</p>
<UButton v-if="plans.length > 1" icon="i-heroicons-trash" size="xs" color="red" variant="ghost" @click="removePlan(i)" />
</div>
<div class="grid grid-cols-2 gap-3">
<UFormField label="Name" required><UInput v-model="plan.name" placeholder="Basic / Standard / Premium" class="w-full" /></UFormField>
<UFormField label="Premium (USD)" required><UInput v-model="plan.premium" type="number" class="w-full" /></UFormField>
<UFormField label="Deductible"><UInput v-model="plan.deductible" type="number" class="w-full" /></UFormField>
<UFormField label="Coverage Limit"><UInput v-model="plan.coverage_limit" type="number" class="w-full" /></UFormField>
</div>
<UFormField label="Coverage Details" required>
<UTextarea v-model="plan.coverage_details" :rows="2" class="w-full" />
</UFormField>
</div>
</div>
</div>
<div class="p-6 border-t flex justify-end gap-3">
<UButton color="gray" variant="soft" @click="isRespondOpen = false">Cancel</UButton>
<UButton color="primary" icon="i-heroicons-paper-airplane" :loading="submitting" :disabled="!isRespondFormValid" @click="submitResponse">
Submit Response
</UButton>
</div>
</div>
</template>
</USlideover>
<!-- Issue policy slideover -->
<USlideover v-model:open="isIssueOpen" side="right">
<template #content>
<div class="flex flex-col h-full">
<div class="flex justify-between items-center p-6 border-b">
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Issue Policy</h2>
<p class="text-sm text-gray-500">Enter the policy details from the provider</p>
</div>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isIssueOpen = false" />
</div>
<div class="flex-1 p-6 space-y-4">
<UFormField label="Policy Number" required>
<UInput v-model="issueForm.policy_number" placeholder="POL-2026-001" class="w-full" />
</UFormField>
<UFormField label="Effective Date" required>
<UInput v-model="issueForm.effective_date" type="date" class="w-full" />
</UFormField>
<UFormField label="Expiry Date" required>
<UInput v-model="issueForm.expiry_date" type="date" class="w-full" />
</UFormField>
</div>
<div class="p-6 border-t flex justify-end gap-3">
<UButton color="gray" variant="soft" @click="isIssueOpen = false">Cancel</UButton>
<UButton color="primary" icon="i-heroicons-document-check" :loading="issuing" :disabled="!isIssueFormValid" @click="submitIssuePolicy">
Issue Policy
</UButton>
</div>
</div>
</template>
</USlideover>
</div>
</template>

View File

@@ -1,176 +0,0 @@
<script setup lang="ts">
import type { SelectItem } from '@nuxt/ui'
const page = ref(1)
const statusFilter = ref<string | null>(null)
const policyTypeFilter = ref<string | null>(null)
const commTypeFilter = ref<string | null>(null)
const statusItems = ref<SelectItem[]>([
{ label: 'All Statuses', value: null },
{ label: 'Pending', value: 'pending' },
{ label: 'Responded', value: 'responded' }
])
const policyTypeItems = ref<SelectItem[]>([
{ label: 'All Types', value: null },
{ label: 'Car', value: 'car' },
{ label: 'Life', value: 'life' },
{ label: 'Fire', value: 'fire' }
])
const commTypeItems = ref<SelectItem[]>([
{ label: 'All Comm Types', value: null },
{ label: 'Quote', value: 'quote' },
{ label: 'Solicitation', value: 'solicitation' }
])
watch([statusFilter, policyTypeFilter, commTypeFilter], () => { page.value = 1 })
const { data, pending, error, refresh } = useTasks('/tasks', {
query: computed(() => ({
page: page.value,
limit: 20,
...(statusFilter.value && { status: statusFilter.value }),
...(policyTypeFilter.value && { policy_type: policyTypeFilter.value }),
...(commTypeFilter.value && { comm_type: commTypeFilter.value })
}))
})
const tasks = computed(() => data.value?.tasks ?? [])
const total = computed(() => data.value?.total ?? 0)
const totalPages = computed(() => Math.ceil(total.value / 20))
const statusColor = (status: string) => {
switch (status) {
case 'pending': return 'yellow'
case 'responded': return 'green'
default: return 'gray'
}
}
const policyTypeColor = (type: string) => {
switch (type) {
case 'car': return 'blue'
case 'life': return 'purple'
case 'fire': return 'orange'
default: return 'gray'
}
}
const formatDate = (date: string) => {
if (!date) return '—'
return new Date(date).toLocaleDateString('es-PA', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})
}
</script>
<template>
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Tasks</h1>
<p class="text-[13px] text-[var(--text-muted)]">Carrier Inbox Quote & Solicitation Requests</p>
</div>
<div class="flex items-center gap-3">
<UBadge color="gray" variant="soft" size="lg">{{ total }} tasks</UBadge>
<UButton
icon="i-heroicons-arrow-path"
color="gray"
variant="soft"
:loading="pending"
@click="refresh()"
>
Refresh
</UButton>
</div>
</div>
<!-- Filters -->
<div class="flex gap-4 items-center flex-wrap">
<USelect v-model="statusFilter" :items="statusItems" class="w-44" />
<USelect v-model="policyTypeFilter" :items="policyTypeItems" class="w-44" />
<USelect v-model="commTypeFilter" :items="commTypeItems" class="w-44" />
</div>
<UAlert
v-if="error"
color="red"
variant="soft"
title="Failed to load tasks"
:description="error.message"
/>
<div v-else-if="pending && tasks.length === 0" class="grid gap-4">
<UCard v-for="n in 5" :key="n">
<div class="h-20 animate-pulse bg-gray-200 rounded" />
</UCard>
</div>
<template v-else>
<div class="space-y-3" :class="pending ? 'opacity-60 pointer-events-none' : ''">
<NuxtLink
v-for="task in tasks"
:key="task.id"
:to="`/tasks/${task.id}`"
>
<UCard class="hover:shadow-md transition-shadow cursor-pointer">
<div class="flex items-center justify-between gap-4">
<!-- Left -->
<div class="flex items-center gap-4 min-w-0">
<div class="flex flex-col gap-1">
<UBadge :color="statusColor(task.status)" variant="soft" size="xs">
{{ task.status }}
</UBadge>
<UBadge :color="policyTypeColor(task.policy_type)" variant="outline" size="xs">
{{ task.policy_type?.toUpperCase() }}
</UBadge>
</div>
<div class="min-w-0">
<p class="font-mono text-sm font-medium text-[var(--text-primary)] truncate">
{{ task.application_id }}
</p>
<p class="text-xs text-gray-400">
Provider: <span class="font-mono">{{ task.provider_id }}</span>
</p>
</div>
</div>
<!-- Right -->
<div class="flex items-center gap-6 flex-shrink-0 text-sm text-gray-500">
<div class="text-right">
<p class="text-xs text-gray-400">Comm Type</p>
<UBadge color="gray" variant="soft" size="xs">{{ task.comm_type }}</UBadge>
</div>
<div class="text-right">
<p class="text-xs text-gray-400">Received</p>
<p>{{ formatDate(task.created_at) }}</p>
</div>
<UIcon name="i-heroicons-chevron-right" class="w-4 h-4 text-gray-400" />
</div>
</div>
</UCard>
</NuxtLink>
<div v-if="tasks.length === 0 && !pending" class="text-center py-16 text-gray-400">
<UIcon name="i-heroicons-inbox" class="w-12 h-12 mx-auto mb-4" />
<p class="text-lg font-medium">No tasks found</p>
<p class="text-sm">Adjust your filters or wait for new requests</p>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex justify-center">
<UPagination
v-model="page"
:total="total"
:page-count="20"
/>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,49 @@
const CLIENTS = ['customer', 'policy', 'providers', 'workload', 'document'] as const
const ORG_STORAGE_KEY = 'policy-ui.selected-org-id'
const setAuthHeader = (ctx: { options: { headers?: Headers | Record<string, string> | undefined } }, token: string) => {
const headers = ctx.options.headers
if (headers instanceof Headers) {
if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`)
} else {
const h = (headers ?? {}) as Record<string, string>
if (!h.Authorization && !h.authorization) {
ctx.options.headers = { ...h, Authorization: `Bearer ${token}` }
}
}
}
const setOrgHeader = (ctx: { options: { headers?: Headers | Record<string, string> | undefined } }, orgId: string) => {
const headers = ctx.options.headers
if (headers instanceof Headers) {
if (!headers.has('x-organization-id')) headers.set('x-organization-id', orgId)
} else {
const h = (headers ?? {}) as Record<string, string>
if (!h['x-organization-id']) {
ctx.options.headers = { ...h, 'x-organization-id': orgId }
}
}
}
export default defineNuxtPlugin({
name: 'open-fetch-auth',
setup(nuxtApp) {
for (const client of CLIENTS) {
const hook = `openFetch:onRequest:${client}` as const
nuxtApp.hook(hook, (ctx) => {
const { data } = useAuth()
const token = data.value?.user?.accessToken as string | undefined
if (!token) return
setAuthHeader(ctx, token)
if (import.meta.client) {
const orgId = localStorage.getItem(ORG_STORAGE_KEY)
if (orgId) {
setOrgHeader(ctx, orgId)
}
}
})
}
}
})

View File

@@ -1,20 +0,0 @@
export default defineNuxtPlugin({
name: 'open-fetch-policy-auth',
setup(nuxtApp) {
const { policyApiToken } = useRuntimeConfig().public
const token = typeof policyApiToken === 'string' ? policyApiToken : ''
if (!token) return
nuxtApp.hook('openFetch:onRequest:policy', (ctx) => {
const headers = ctx.options.headers
if (headers instanceof Headers) {
if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`)
} else {
const h = (headers ?? {}) as Record<string, string>
if (!h.Authorization && !h.authorization) {
ctx.options.headers = { ...h, Authorization: `Bearer ${token}` }
}
}
})
}
})

View File

@@ -29,8 +29,7 @@
inherit pname version src;
pnpm = pkgs.pnpm;
fetcherVersion = 3;
# Placeholder hash - run nix build to get the correct hash
hash = "sha256-b9rYE09uEFLoiymS6PUkWJvOwMnniBbvPbFp6xu+PtM=";
hash = "sha256-Z045R87Mgu4FReVqcgCn2PR4THMlcZoG3NEuBy6JEwI=";
};
env.NUXT_TELEMETRY_DISABLED = 1;
@@ -49,17 +48,17 @@
name = "policy-ui";
contents = [
pkgs.nodejs
pkgs.dockerTools.caCertificates
self.packages.${system}.policy-ui
];
config = {
Cmd = [ "node" "/server/index.mjs" ];
Env = {
NODE_ENV = "production";
PORT = "3000";
HOST = "0.0.0.0";
NUXT_TELEMETRY_DISABLED = "1";
};
ExposedPorts = [ "3000/tcp" ];
Env = [
"NODE_ENV=production"
"PORT=3000"
"HOST=0.0.0.0"
"NUXT_TELEMETRY_DISABLED=1"
];
};
};
packages.default = self.packages.${system}.policy-ui;

View File

@@ -1,23 +1,33 @@
const devApiOrigin = 'https://dev.api.corredorconect.com'
export default defineNuxtConfig({
compatibilityDate: '2026-02-25',
/** Dev server URL — open the same port in the browser (see terminal if the port was busy). */
modules: ['nuxt-open-fetch', '@nuxt/ui', '@nuxt/eslint'],
modules: ['nuxt-open-fetch', '@nuxt/ui', '@nuxt/eslint', '@zitadel/nuxt-auth'],
ssr: false,
auth: {
provider: {
type: 'authjs',
trustHost: true,
defaultProvider: 'zitadel',
},
sessionRefresh: {
enablePeriodically: false,
enableOnWindowFocus: false,
},
},
css: ['~/assets/css/main.css'],
ui: {
colorMode: false
},
runtimeConfig: {
public: {
/**
* Sent as `Authorization: Bearer …` on Policy API requests (required for protected routes per OpenAPI).
* Set in `.env` as `NUXT_PUBLIC_POLICY_API_TOKEN`.
*/
policyApiToken: process.env.NUXT_PUBLIC_POLICY_API_TOKEN ?? ''
}
// can be overriden NUXT_AUTH_SECRET
authSecret: '',
zitadelDomain: '',
zitadelClientId: '',
zitadelProjectId: ''
},
openFetch: {
openFetch: {
clients: {
customer: {
baseURL:

View File

@@ -5,7 +5,80 @@
"type": "remote",
"url": "https://ui.nuxt.com/mcp",
"enabled": true
},
"browsermcp": {
"type": "local",
"command": [
"npx",
"-y",
"@browsermcp/mcp@0.1.3"
],
"enabled": true
},
"stacklit": {
"type": "local",
"command": [
"npx",
"stacklit",
"serve"
],
"enabled": true
}
},
"provider": {
"corredorconect": {
"npm": "@ai-sdk/openai-compatible",
"name": "Corredor Conect",
"options": {
"baseURL": "https://mcp.corredorconect.com/v1",
"headers": {
"Authorization": "Bearer cc-itsjusfdsawrtwtavfdsfsderysectrwurekey12345"
}
},
"models": {
"Qwen3.6-27B": {
"name": "Qwen3.6-27B",
"tools": true
}
}
}
},
"plugin": [
"opencode-browser"
],
"agent": {
"bug-finder": {
"description": "Systematically tests all UI pages using webmcp and documents bugs in BUGS.md",
"model": "z-ai/glm4.7",
"prompt": "You are a UI bug finder agent. Your task is to systematically test all pages in the application using browsermcp tools and document any bugs found in BUGS.md.\n\nTesting Methodology:\n1. Start at http://localhost:3000/\n2. Navigate through all pages systematically\n3. For each page, test: page loads without errors, data displays correctly, forms validate properly, interactive elements work, responsive layout, page refresh behavior, navigation links work, no console errors\n4. Document bugs with priority classification (Critical, High, Medium, Low)\n5. Use the exact format from existing BUGS.md for consistency\n\nPages to test (in order):\n- /, /customers, /customers/new, /customers/[id]\n- /policies, /policies/new, /policies/[id], /policies/book, /policies/groups\n- /quotes/new (test all tabs: car, life, fire_structure, fire_contents)\n- /providers, /providers/new, /providers/[provider_id]\n- /settings and all subpages\n- /workstation pages (customer-service, renewals, facturacion, collectivos, claims, collections)\n- /calendar, /collections, /analysis, /account\n- /ai-tools pages (sales-factory, policy-comparator, email-writer, case-assistant)\n- /back-office/workload, /back-office/workload/[id], /back-office/workload/kanban\n- /sales/leads, /sales/quick-lead\n\nBug Documentation Format:\n### [Priority] Bug Title\n- **Issue**: Clear description\n- **Location**: Page/route\n- **Impact**: User experience severity \n- **Status**: Pending\n- **Test Results**: Specific observations\n\nTrack visited URLs and update BUGS.md with findings.",
"tools": {
"browsermcp_browser_navigate": true,
"browsermcp_browser_snapshot": true,
"browsermcp_browser_click": true,
"browsermcp_browser_type": true,
"browsermcp_browser_select_option": true,
"browsermcp_browser_screenshot": true,
"browsermcp_browser_get_console_logs": true,
"read": true,
"write": true,
"edit": true,
"glob": true,
"grep": true
}
},
"bug-fixer": {
"description": "Reads BUGS.md and systematically fixes all documented bugs",
"model": "z-ai/glm4.7",
"prompt": "You are a bug fixer agent. Your task is to read BUGS.md and systematically fix all documented bugs.\n\nFix Process:\n1. Read BUGS.md to identify all pending bugs\n2. Categorize bugs by type (routing, validation, layout, data, UI)\n3. Fix bugs in severity order: Critical → High → Medium → Low\n4. For each bug:\n - Locate affected files using glob and grep\n - Understand root cause by reading relevant code\n - Implement fix following existing patterns and conventions\n - Update bug status in BUGS.md with ✅ FIXED\n - Add notes about what was fixed\n\nCode Quality Guidelines:\n- Follow existing code conventions and patterns\n- Use existing libraries and utilities\n- Maintain consistency with similar fixes\n- No comments unless requested\n- Test fixes if possible\n\nAfter fixing all bugs, provide summary of fixes made.",
"tools": {
"read": true,
"write": true,
"edit": true,
"glob": true,
"grep": true,
"bash": true
}
}
}
}

View File

@@ -17,7 +17,6 @@ controllers:
value: "0.0.0.0"
- name: NUXT_TELEMETRY_DISABLED
value: "1"
# API endpoints (hardcoded to dev)
- name: NUXT_PUBLIC_CUSTOMER_API_BASE
value: "https://dev.api.corredorconect.com/customer/api/v1"
- name: NUXT_PUBLIC_POLICY_API_BASE
@@ -28,12 +27,25 @@ controllers:
value: "https://dev.api.corredorconect.com/workload/api/v1"
- name: NUXT_PUBLIC_DOCUMENT_API_BASE
value: "https://dev.api.corredorconect.com/document/api/v1"
# Policy API token from secret
# - name: NUXT_PUBLIC_POLICY_API_TOKEN
# valueFrom:
# secretKeyRef:
# name: policy-ui-secrets
# key: policyApiToken
- name: AUTH_ORIGIN
value: https://dev.corredorconect.com/api/auth
- name: NUXT_ZITADEL_DOMAIN
value: https://id.corredorconect.com
- name: NUXT_ZITADEL_CLIENT_ID
valueFrom:
secretKeyRef:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-oidcapp-client-secret'
key: clientId
- name: NUXT_ZITADEL_PROJECT_ID
valueFrom:
secretKeyRef:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-oidcapp-client-secret'
key: projectId
- name: NUXT_AUTH_SECRET
valueFrom:
secretKeyRef:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets'
key: auth-secret
ports:
- name: http
containerPort: 3000
@@ -70,3 +82,65 @@ service:
ingress:
main:
enabled: false
rawResources:
password-generator:
enabled: true
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
suffix: password-generator
spec:
spec:
length: 32
noUpper: false
allowRepeat: true
secretKeys:
- auth-secret
external-secret:
enabled: true
apiVersion: external-secrets.io/v1
kind: ExternalSecret
suffix: secrets
spec:
spec:
refreshInterval: 0s
secretStoreRef:
name: cluster-secrets-store
kind: ClusterSecretStore
target:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets'
creationPolicy: Owner
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-password-generator'
oidcapp:
apiVersion: zitadel.github.com/v1alpha1
kind: OIDCApp
suffix: oidcapp
spec:
spec:
oidcAppName: policy-ui
projectRef:
name: seguros-dev
namespace: zitadel-resources-operator
appType: OIDC_APP_TYPE_WEB
authMethodType: OIDC_AUTH_METHOD_TYPE_NONE
redirectUris:
- https://dev.corredorconect.com/api/auth/callback/zitadel
postLogoutRedirectUris:
- https://dev.corredorconect.com/
responseTypes:
- OIDC_RESPONSE_TYPE_CODE
grantTypes:
- OIDC_GRANT_TYPE_AUTHORIZATION_CODE
accessTokenRoleAssertion: true
accessTokenType: OIDC_TOKEN_TYPE_JWT
idTokenRoleAssertion: true
idTokenUserinfoAssertion: true
clockSkew: 5s
devMode: false
skipNativeAppSuccessPage: false

View File

@@ -10,9 +10,12 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@auth/core": "^0.40.0",
"@nuxt/eslint": "^1.15.2",
"@nuxt/ui": "^4.5.0",
"@zitadel/nuxt-auth": "^1.0.0",
"jspdf": "^4.2.0",
"jwt-decode": "^4.0.0",
"nuxt": "^4.3.1",
"nuxt-open-fetch": "^0.13.8",
"tailwindcss": "^4.2.1",

98
pnpm-lock.yaml generated
View File

@@ -8,15 +8,24 @@ importers:
.:
dependencies:
'@auth/core':
specifier: ^0.40.0
version: 0.40.0
'@nuxt/eslint':
specifier: ^1.15.2
version: 1.15.2(@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))
'@nuxt/ui':
specifier: ^4.5.0
version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6)
version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6)
'@zitadel/nuxt-auth':
specifier: ^1.0.0
version: 1.0.0(@auth/core@0.40.0)(@nuxt/schema@4.4.2)(magicast@0.5.2)(radix3@1.1.2)(vue@3.5.32(typescript@5.9.3))
jspdf:
specifier: ^4.2.0
version: 4.2.1
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
nuxt:
specifier: ^4.3.1
version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4)(eslint@10.2.1(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(srvx@0.11.15)(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(yaml@2.8.3)
@@ -51,6 +60,20 @@ packages:
peerDependencies:
'@types/json-schema': ^7.0.15
'@auth/core@0.40.0':
resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -1087,6 +1110,9 @@ packages:
'@package-json/types@0.0.12':
resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
@@ -2151,6 +2177,15 @@ packages:
peerDependencies:
vue: ^3.5.0
'@zitadel/nuxt-auth@1.0.0':
resolution: {integrity: sha512-kQpUwjvtkvF20+sAop+VrayJJb51Pdc+l3OQ04ZXlTunrkKQFzO13sSwPU+v1u3UmAwYXAkjUDf5zIzMTtHCNg==}
engines: {node: '>=22.0.0'}
peerDependencies:
'@auth/core': '>=0.40.0'
'@nuxt/schema': ^4.0.0
radix3: ^1.0.0
vue: ^3.0.0
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -3334,6 +3369,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
@@ -3380,6 +3418,10 @@ packages:
jspdf@4.2.1:
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3742,6 +3784,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
object-deep-merge@2.0.0:
resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==}
@@ -4082,6 +4127,14 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -5087,6 +5140,14 @@ snapshots:
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@auth/core@0.40.0':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
oauth4webapi: 3.8.6
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -5965,7 +6026,7 @@ snapshots:
rc9: 3.0.1
std-env: 4.1.0
'@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6)':
'@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6)':
dependencies:
'@floating-ui/dom': 1.7.6
'@iconify/vue': 5.0.0(vue@3.5.32(typescript@5.9.3))
@@ -6000,7 +6061,7 @@ snapshots:
'@tiptap/vue-3': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(vue@3.5.32(typescript@5.9.3))
'@unhead/vue': 2.1.13(vue@3.5.32(typescript@5.9.3))
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3))
'@vueuse/integrations': 14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(vue@3.5.32(typescript@5.9.3))
'@vueuse/integrations': 14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(jwt-decode@4.0.0)(vue@3.5.32(typescript@5.9.3))
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3))
colortranslator: 5.0.0
consola: 3.4.2
@@ -6346,6 +6407,8 @@ snapshots:
'@package-json/types@0.0.12': {}
'@panva/hkdf@1.2.1': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
@@ -7303,7 +7366,7 @@ snapshots:
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
'@vueuse/integrations@14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(vue@3.5.32(typescript@5.9.3))':
'@vueuse/integrations@14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(jwt-decode@4.0.0)(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3))
'@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3))
@@ -7311,6 +7374,7 @@ snapshots:
optionalDependencies:
change-case: 5.4.4
fuse.js: 7.3.0
jwt-decode: 4.0.0
'@vueuse/metadata@10.11.1': {}
@@ -7327,6 +7391,20 @@ snapshots:
dependencies:
vue: 3.5.32(typescript@5.9.3)
'@zitadel/nuxt-auth@1.0.0(@auth/core@0.40.0)(@nuxt/schema@4.4.2)(magicast@0.5.2)(radix3@1.1.2)(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@auth/core': 0.40.0
'@nuxt/kit': 4.4.2(magicast@0.5.2)
'@nuxt/schema': 4.4.2
consola: 3.4.2
defu: 6.1.7
h3: 1.15.11
radix3: 1.1.2
ufo: 1.6.3
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- magicast
abbrev@3.0.1: {}
abort-controller@3.0.0:
@@ -8519,6 +8597,8 @@ snapshots:
jiti@2.6.1: {}
jose@6.2.3: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
@@ -8559,6 +8639,8 @@ snapshots:
dompurify: 3.4.0
html2canvas: 1.4.1
jwt-decode@4.0.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -9110,6 +9192,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.1.1
oauth4webapi@3.8.6: {}
object-deep-merge@2.0.0: {}
obug@2.1.1: {}
@@ -9505,6 +9589,12 @@ snapshots:
powershell-utils@0.1.0: {}
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
prelude-ls@1.2.1: {}
pretty-bytes@7.1.0: {}

67
server/api/auth/[...].ts Normal file
View File

@@ -0,0 +1,67 @@
import { NuxtAuthHandler } from '#auth'
import ZitadelProvider from '@auth/core/providers/zitadel'
import { jwtDecode } from 'jwt-decode'
const config = useRuntimeConfig()
export default NuxtAuthHandler({
secret: config.authSecret,
providers: [
ZitadelProvider({
clientId: config.zitadelClientId,
issuer: config.zitadelDomain,
pkce: true,
authorization: {
params: {
scope: `openid email profile offline_access urn:zitadel:iam:org:project:${config.zitadelProjectId}:aud urn:zitadel:iam:org:project:${config.zitadelProjectId}:roles`
}
}
})
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async jwt({ token, account, user }) {
if (account?.provider === 'zitadel') {
token.accessToken = account.access_token
token.idToken = account.id_token
}
if (user?.id) {
token.sub = user.id
if (user.name || (user as any).profile?.given_name) {
token.name = user.name || ((user as any).profile?.given_name || '')
}
token.email = user.email || ''
token.image = user.image || undefined
}
return token
},
async session({ session, token }) {
const user = session.user as any
if (user) {
user.name = token.name || undefined
user.email = token.email || undefined
user.image = token.image || undefined
user.accessToken = token.accessToken as string | undefined
// Decode idToken and extract org roles claim
if (token.idToken) {
try {
const decoded = jwtDecode(token.idToken)
const roles = decoded[`urn:zitadel:iam:org:project:${config.zitadelProjectId}:roles`]
user.roles = roles
} catch (error) {
console.error('Failed to decode idToken:', error)
}
}
}
return session
},
async redirect({ url, baseUrl }) {
if (url === '/login') return '/login'
return url.startsWith(baseUrl) ? url : baseUrl
}
}
})

23
types/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
import type { DefaultSession } from '@auth/core/types'
declare module '@auth/core/types' {
interface Session {
user: {
roles?: Record<string, Record<string, Record<string, string>>>
accessToken?: string
} & DefaultSession['user']
}
interface User {
roles?: Record<string, Record<string, Record<string, string>>>
}
}
declare module '#auth' {
interface Session {
user: {
roles?: Record<string, Record<string, Record<string, string>>>
accessToken?: string
} & DefaultSession['user']
}
}