WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

View File

@@ -0,0 +1,417 @@
<script setup lang="ts">
import type { SelectItem } from '@nuxt/ui'
import type { ClientRegistrationNatural } from '~/types/brokerage-registration'
import {
createEmptyClientRegistration,
toIndividualCustomerBody,
useClientCaptureMeta
} from '~/composables/useClientRegistrationModel'
definePageMeta({ ssr: false })
usePageTitle('Client registration')
const route = useRoute()
const router = useRouter()
const submitting = ref(false)
const toast = useToast()
const { $customer } = useNuxtApp()
/* ── Success state ── */
const createdCustomerId = ref<string | null>(null)
const createdCustomerName = ref('')
const showSuccess = computed(() => !!createdCustomerId.value)
const customerType = ref<'individual' | 'corporate'>(
route.query.type === 'corporate' ? 'corporate' : 'individual'
)
const form = ref<ClientRegistrationNatural>(createEmptyClientRegistration())
const captureMeta = ref(useClientCaptureMeta())
const corporateForm = ref({
legal_name: '',
commercial_name: '',
ruc: '',
legal_rep_name: '',
legal_rep_document_id: '',
email: '',
phone: '',
address: ''
})
const idTypeItems = ref<SelectItem[]>([
{ label: 'Cédula', value: 'cedula' },
{ label: 'Pasaporte', value: 'pasaporte' },
{ label: 'RUC', value: 'ruc' }
])
const isValidIndividual = computed(() => {
const f = form.value
return (
!!f.primerNombre.trim() &&
!!f.apellidoPaterno.trim() &&
!!f.correoElectronicoPersonal.trim() &&
!!f.cedulaOPasaporte.trim()
)
})
const isValidCorporate = computed(() => corporateForm.value.legal_name && corporateForm.value.ruc)
async function submitIndividual() {
submitting.value = true
try {
const body = toIndividualCustomerBody(form.value)
const data = (await $customer('/customers', {
method: 'POST',
body
})) as { data?: { id: string } }
toast.add({ title: 'Customer created', color: 'success' })
createdCustomerName.value = `${form.value.primerNombre} ${form.value.apellidoPaterno}`.trim()
createdCustomerId.value = data?.data?.id ?? 'new'
} catch (e: unknown) {
const err = e as { data?: { errors?: unknown }; message?: string }
toast.add({
title: 'Failed to create customer',
description: err?.data?.errors ? JSON.stringify(err.data.errors) : err?.message,
color: 'error'
})
} finally {
submitting.value = false
}
}
async function submitCorporate() {
submitting.value = true
try {
const data = (await $customer('/customers/corporate', {
method: 'POST',
body: corporateForm.value
})) as { data?: { id: string } }
toast.add({ title: 'Corporate customer created', color: 'success' })
createdCustomerName.value = corporateForm.value.legal_name || corporateForm.value.commercial_name
createdCustomerId.value = data?.data?.id ?? 'new'
} catch (e: unknown) {
const err = e as { data?: { errors?: unknown }; message?: string }
toast.add({
title: 'Failed to create customer',
description: err?.data?.errors ? JSON.stringify(err.data.errors) : err?.message,
color: 'error'
})
} finally {
submitting.value = false
}
}
function resetAndAddAnother() {
createdCustomerId.value = null
createdCustomerName.value = ''
form.value = createEmptyClientRegistration()
corporateForm.value = {
legal_name: '', commercial_name: '', ruc: '',
legal_rep_name: '', legal_rep_document_id: '',
email: '', phone: '', address: ''
}
}
</script>
<template>
<div class="nc mx-auto max-w-5xl space-y-6 pb-12">
<!-- Back -->
<NuxtLink to="/customers" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Customers</UButton>
</NuxtLink>
<!-- Sales flow indicator -->
<SalesFlowIndicator current-stage="customer" />
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Customer Registration</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Extended capture aligned with brokerage intake. Core fields map to the customer API.
</p>
</div>
<div class="nc-meta-card">
<div class="nc-meta-row">
<span class="nc-meta-label">Operator</span>
<span class="nc-meta-value">{{ captureMeta.operadorNombre }}</span>
</div>
<div class="nc-meta-row">
<span class="nc-meta-label">Progress</span>
<span class="nc-meta-value">{{ captureMeta.progresoCapturaPct }}%</span>
</div>
</div>
</div>
<!-- Type toggle -->
<div class="nc-tabs">
<button
v-for="type in [
{ id: 'individual' as const, label: 'Persona natural', icon: 'i-heroicons-user' },
{ id: 'corporate' as const, label: 'Persona jurídica', icon: 'i-heroicons-building-office' }
]"
:key="type.id"
type="button"
class="nc-tab"
:class="customerType === type.id ? 'nc-tab-on' : 'nc-tab-off'"
@click="customerType = type.id"
>
<UIcon :name="type.icon" style="width: 14px; height: 14px;" />
{{ type.label }}
</button>
</div>
<!-- ═══ Success state ═══ -->
<div v-if="showSuccess" class="nc-success">
<div class="nc-success-icon">
<UIcon name="i-heroicons-check-circle" style="width: 32px; height: 32px;" />
</div>
<h2 class="mt-4 text-[17px] font-semibold text-[var(--text-primary)]">Customer registered</h2>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
<strong class="font-medium text-[var(--text-primary)]">{{ createdCustomerName }}</strong> has been added to your customer base.
</p>
<p class="mt-1 text-[12px] text-[var(--text-muted)]">What would you like to do next?</p>
<div class="nc-success-actions">
<NuxtLink :to="`/quotes/new?customer=${createdCustomerId}`" class="nc-success-btn nc-success-primary">
<UIcon name="i-heroicons-calculator" style="width: 16px; height: 16px;" />
Proceed to quote
</NuxtLink>
<NuxtLink :to="`/customers/${createdCustomerId}`" class="nc-success-btn nc-success-secondary">
<UIcon name="i-heroicons-user" style="width: 16px; height: 16px;" />
View profile
</NuxtLink>
<button type="button" class="nc-success-btn nc-success-secondary" @click="resetAndAddAnother">
<UIcon name="i-heroicons-plus" style="width: 16px; height: 16px;" />
Register another
</button>
</div>
</div>
<div v-if="!showSuccess && customerType === 'individual'" class="nc-card">
<div class="nc-card-head">
<UIcon name="i-heroicons-identification" style="width: 16px; height: 16px; color: #01696f;" />
<span>Datos del cliente</span>
</div>
<div class="nc-card-body space-y-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<UFormField label="ID (Mint)">
<UInput v-model="form.id" placeholder="Auto" disabled class="w-full" />
</UFormField>
<UFormField label="Grupo económico">
<UInput v-model="form.economicGroupId" class="w-full" />
</UFormField>
<UFormField label="Conglomerado">
<UInput v-model="form.conglomerateId" class="w-full" />
</UFormField>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<UFormField label="Apellido paterno" required>
<UInput v-model="form.apellidoPaterno" class="w-full" />
</UFormField>
<UFormField label="Apellido materno">
<UInput v-model="form.apellidoMaterno" class="w-full" />
</UFormField>
<UFormField label="Primer nombre" required>
<UInput v-model="form.primerNombre" class="w-full" />
</UFormField>
<UFormField label="Segundo nombre">
<UInput v-model="form.segundoNombre" class="w-full" />
</UFormField>
<UFormField label="Fecha de nacimiento">
<UInput v-model="form.fechaNacimiento" type="date" class="w-full" />
</UFormField>
<UFormField label="Tipo de identificación">
<USelect v-model="form.tipoIdentificacion" :items="idTypeItems" class="w-full" />
</UFormField>
<UFormField label="Cédula / pasaporte" required>
<UInput v-model="form.cedulaOPasaporte" class="w-full" />
</UFormField>
<UFormField label="Teléfono celular">
<UInput v-model="form.telefonoCelular" class="w-full" />
</UFormField>
<UFormField label="Correo electrónico" required>
<UInput v-model="form.correoElectronicoPersonal" type="email" class="w-full" />
</UFormField>
<UFormField label="Ocupación">
<UInput v-model="form.ocupacion" class="w-full" />
</UFormField>
</div>
<div class="grid grid-cols-1 gap-4">
<UFormField label="Procedencia">
<UInput v-model="form.procedencia" class="w-full" />
</UFormField>
<UFormField label="Detalle">
<UTextarea v-model="form.detalle" :rows="2" class="w-full" />
</UFormField>
<UFormField label="Descripción">
<UTextarea v-model="form.descripcion" :rows="3" class="w-full" />
</UFormField>
</div>
</div>
<div class="nc-card-footer">
<NuxtLink to="/customers">
<UButton color="neutral" variant="soft" size="sm">Cancel</UButton>
</NuxtLink>
<UButton
color="primary"
icon="i-heroicons-check"
size="sm"
:loading="submitting"
:disabled="!isValidIndividual"
@click="submitIndividual"
>
Guardar cliente
</UButton>
</div>
</div>
<div v-if="!showSuccess && customerType === 'corporate'" class="nc-card">
<div class="nc-card-head">
<UIcon name="i-heroicons-building-office" style="width: 16px; height: 16px; color: #01696f;" />
<span>Persona jurídica</span>
</div>
<div class="nc-card-body space-y-4">
<UFormField label="Legal name" required>
<UInput v-model="corporateForm.legal_name" class="w-full" />
</UFormField>
<UFormField label="Commercial name">
<UInput v-model="corporateForm.commercial_name" class="w-full" />
</UFormField>
<UFormField label="RUC" required>
<UInput v-model="corporateForm.ruc" class="w-full" />
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="Legal representative">
<UInput v-model="corporateForm.legal_rep_name" class="w-full" />
</UFormField>
<UFormField label="Legal rep document ID">
<UInput v-model="corporateForm.legal_rep_document_id" class="w-full" />
</UFormField>
</div>
<UFormField label="Email">
<UInput v-model="corporateForm.email" type="email" class="w-full" />
</UFormField>
<UFormField label="Phone">
<UInput v-model="corporateForm.phone" class="w-full" />
</UFormField>
<UFormField label="Address">
<UInput v-model="corporateForm.address" class="w-full" />
</UFormField>
</div>
<div class="nc-card-footer">
<NuxtLink to="/customers">
<UButton color="neutral" variant="soft" size="sm">Cancel</UButton>
</NuxtLink>
<UButton
color="primary"
icon="i-heroicons-check"
size="sm"
:loading="submitting"
:disabled="!isValidCorporate"
@click="submitCorporate"
>
Create corporate customer
</UButton>
</div>
</div>
</div>
</template>
<style scoped>
.nc-section-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: #8a8a86; margin-bottom: 4px;
}
.nc-meta-card {
padding: 10px 16px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06); background: #ffffff;
font-size: 12px;
}
.nc-meta-row { display: flex; gap: 12px; padding: 2px 0; }
.nc-meta-label { color: #8a8a86; min-width: 60px; }
.nc-meta-value { color: var(--text-primary); font-weight: 500; }
.nc-tabs {
display: inline-flex; gap: 2px; padding: 3px;
border-radius: 10px; background: rgba(0,0,0,0.04);
}
.nc-tab {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px;
font-size: 13px; font-weight: 500;
border: none; cursor: pointer; transition: all 150ms ease;
}
.nc-tab-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.nc-tab-off { background: transparent; color: var(--text-muted); }
.nc-tab-off:hover { color: var(--text-primary); }
.nc-card {
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow: hidden;
}
.nc-card-head {
display: flex; align-items: center; gap: 8px;
padding: 14px 20px; border-bottom: 1px solid rgba(0,0,0,0.06);
font-size: 13px; font-weight: 600; color: var(--text-primary);
}
.nc-card-body { padding: 20px; }
.nc-card-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 14px 20px; border-top: 1px solid rgba(0,0,0,0.06);
}
/* ── Success state ── */
.nc-success {
padding: 48px 24px;
border-radius: 12px;
border: 1px solid rgba(1,105,111,0.12);
background: rgba(1,105,111,0.02);
text-align: center;
}
.nc-success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px; height: 56px;
border-radius: 12px;
background: rgba(1,105,111,0.08);
color: #01696f;
}
.nc-success-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
}
.nc-success-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
white-space: nowrap;
border: none;
}
.nc-success-primary {
background: #01696f;
color: #fff;
}
.nc-success-primary:hover { background: #015458; }
.nc-success-secondary {
background: #fff;
color: var(--text-primary);
border: 1px solid rgba(0,0,0,0.1);
}
.nc-success-secondary:hover {
border-color: rgba(0,0,0,0.2);
background: rgba(0,0,0,0.02);
}
</style>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import type { PolicyRegistration } from '~/types/brokerage-registration'
import { POLICY_DRAFT_STORAGE_KEY } from '~/types/brokerage-registration'
import {
createEmptyPolicyRegistration,
rebuildInstallmentSchedule,
setFinOneYearAfterInicio,
usePolicyDraftPersistence
} from '~/composables/usePolicyRegistrationModel'
definePageMeta({ ssr: false })
usePageTitle('Policy registration')
const toast = useToast()
const form = ref<PolicyRegistration>(createEmptyPolicyRegistration())
usePolicyDraftPersistence(form)
watch(
() => form.value.inicioVigencia,
() => setFinOneYearAfterInicio(form.value)
)
function refreshSchedule() {
form.value.cuotas = rebuildInstallmentSchedule(form.value)
}
watch(
() => [form.value.numCuotas, form.value.primaBruta, form.value.inicioVigencia] as const,
() => refreshSchedule(),
{ deep: true }
)
onMounted(() => {
refreshSchedule()
})
const draftSavedAt = ref<string | null>(null)
function saveDraft() {
try {
localStorage.setItem(POLICY_DRAFT_STORAGE_KEY, JSON.stringify(form.value))
draftSavedAt.value = new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
toast.add({ title: 'Borrador guardado', color: 'success' })
} catch {
toast.add({ title: 'No se pudo guardar', color: 'error' })
}
}
function clearDraft() {
form.value = createEmptyPolicyRegistration()
refreshSchedule()
try {
localStorage.removeItem(POLICY_DRAFT_STORAGE_KEY)
} catch {
/* ignore */
}
toast.add({ title: 'Borrador borrado', color: 'success' })
}
</script>
<template>
<div class="min-h-screen space-y-8 bg-gray-50 p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-center gap-4">
<NuxtLink to="/policies">
<UButton icon="i-heroicons-arrow-left" color="neutral" variant="ghost">Back</UButton>
</NuxtLink>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Nuevo registro · Póliza</h1>
<p class="text-[13px] text-[var(--text-muted)]">
Condiciones particulares, plan de pagos y referencias. Draft persists in this browser until an API is
wired.
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton color="neutral" variant="soft" @click="saveDraft">Guardar borrador</UButton>
<span v-if="draftSavedAt" class="text-[11px] text-emerald-600 font-medium">
<UIcon name="i-heroicons-check-circle" style="width: 13px; height: 13px; vertical-align: -2px;" /> Saved at {{ draftSavedAt }}
</span>
<UButton color="neutral" variant="ghost" @click="clearDraft">Limpiar</UButton>
<NuxtLink to="/onboarding/policy-upload/new">
<UButton color="primary" variant="soft">Manual policy upload</UButton>
</NuxtLink>
</div>
</div>
<UCard class="max-w-5xl">
<template #header>
<span class="font-semibold text-[var(--text-primary)]">Identificación y ramo</span>
</template>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<UFormField label="Número póliza Mint">
<UInput v-model="form.mintPolicyNumber" disabled class="w-full" />
</UFormField>
<UFormField label="ID contratante">
<UInput v-model="form.contratanteId" class="w-full" />
</UFormField>
<UFormField label="Ramo">
<UInput v-model="form.ramo" class="w-full" />
</UFormField>
<UFormField label="Sub ramo">
<UInput v-model="form.subRamo" class="w-full" />
</UFormField>
<UFormField label="Aseguradora">
<UInput v-model="form.aseguradora" class="w-full" />
</UFormField>
<UFormField label="Producto">
<UInput v-model="form.producto" class="w-full" />
</UFormField>
<UFormField label="Agencia">
<UInput v-model="form.agencia" class="w-full" />
</UFormField>
<UFormField label="Nº póliza proveedor">
<UInput v-model="form.numeroPolizaProveedor" class="w-full" />
</UFormField>
<UFormField label="Acreedor">
<UInput v-model="form.acreedor" class="w-full" />
</UFormField>
</div>
</UCard>
<UCard class="max-w-5xl">
<template #header>
<span class="font-semibold text-[var(--text-primary)]">Vigencia y comisiones</span>
</template>
<div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<UFormField label="Fecha emisión">
<UInput v-model="form.fechaEmision" type="datetime-local" class="w-full" />
</UFormField>
<UFormField label="Inicio vigencia">
<UInput v-model="form.inicioVigencia" type="datetime-local" class="w-full" />
</UFormField>
<UFormField label="Fin vigencia">
<UInput v-model="form.finVigencia" type="datetime-local" class="w-full" />
</UFormField>
</div>
<p class="mb-2 text-xs text-[var(--text-muted)]">Comisiones agente (%)</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div v-for="row in form.comisiones" :key="row.idx" class="flex items-end gap-2">
<UFormField :label="`Agente ${row.idx}`" class="flex-1">
<UInput v-model="row.agenteId" placeholder="ID" class="w-full" />
</UFormField>
<UFormField label="%" class="w-24">
<UInput v-model="row.porcentaje" class="w-full" />
</UFormField>
</div>
</div>
</UCard>
<UCard class="max-w-5xl">
<template #header>
<span class="font-semibold text-[var(--text-primary)]">Primas y forma de pago</span>
</template>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<UFormField label="Forma de pago">
<UInput v-model="form.formaPago" class="w-full" />
</UFormField>
<UFormField label="Valor asegurado">
<UInput v-model="form.valorAsegurado" class="w-full" />
</UFormField>
<UFormField label="Prima bruta">
<UInput v-model="form.primaBruta" class="w-full" />
</UFormField>
<UFormField label="Impuesto %">
<UInput v-model="form.impuestoPct" class="w-full" />
</UFormField>
<UFormField label="Prima neta">
<UInput v-model="form.primaNeta" class="w-full" />
</UFormField>
<UFormField label="Número de cuotas">
<UInput v-model.number="form.numCuotas" type="number" min="1" max="60" class="w-full" />
</UFormField>
</div>
</UCard>
<UCard class="max-w-5xl">
<template #header>
<div class="flex w-full items-center justify-between gap-4">
<span class="font-semibold text-[var(--text-primary)]">Plan de pagos</span>
<UButton size="xs" color="neutral" variant="soft" @click="refreshSchedule">Regenerar cuotas</UButton>
</div>
</template>
<div class="overflow-x-auto rounded-lg border border-[var(--card-border)] bg-[var(--surface)]">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-[var(--card-border)] bg-[var(--surface)] text-left text-[var(--text-muted)]">
<th class="px-3 py-2 font-medium">#</th>
<th class="px-3 py-2 font-medium">Fecha vencimiento</th>
<th class="px-3 py-2 font-medium">Prima</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in form.cuotas"
:key="row.n"
class="border-b border-[var(--divider)] last:border-0"
>
<td class="px-3 py-2 text-[var(--text-muted)]">{{ row.n }}</td>
<td class="px-3 py-2">
<UInput v-model="row.fechaVencimiento" type="datetime-local" size="sm" />
</td>
<td class="px-3 py-2">
<UInput v-model="row.prima" size="sm" />
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
<UCard class="max-w-5xl">
<template #header>
<span class="font-semibold text-[var(--text-primary)]">Referencias y notas</span>
</template>
<div class="space-y-4">
<UFormField label="Cotización Mint ID">
<UInput v-model="form.cotizacionMintId" class="w-full" />
</UFormField>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<UFormField label="PDF cotización (nombre)">
<UInput v-model="form.pdfCotizacionNombre" class="w-full" />
</UFormField>
<UFormField label="PDF póliza (nombre)">
<UInput v-model="form.pdfPolizaNombre" class="w-full" />
</UFormField>
</div>
<UFormField label="Notas">
<UTextarea v-model="form.notas" :rows="4" class="w-full" />
</UFormField>
</div>
</UCard>
</div>
</template>