mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 21:43:17 +00:00
777 lines
25 KiB
Vue
777 lines
25 KiB
Vue
<template>
|
|
<div class="saas-page">
|
|
<header class="saas-header">
|
|
<div>
|
|
<p class="saas-kicker">Operadores PANELS</p>
|
|
<h1>Empresas</h1>
|
|
<p class="saas-sub">Alta de cliente con un admin; la contraseña se genera y se envía por correo.</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<Button label="SMTP" icon="pi pi-envelope" severity="secondary" outlined @click="navigateTo('/smtp')" />
|
|
<Button label="Nueva empresa" icon="pi pi-plus" @click="openCreate" />
|
|
</div>
|
|
</header>
|
|
|
|
<Message v-if="!smtpOk" severity="warn" :closable="false" class="mb-3">
|
|
SMTP no configurado: las contraseñas temporales se muestran aquí para copiarlas.
|
|
<RouterLink to="/smtp" class="smtp-link">Configurar SMTP</RouterLink>
|
|
</Message>
|
|
<Message v-if="error" severity="error" :closable="false" class="mb-3">{{ error }}</Message>
|
|
|
|
<DataTable
|
|
:value="tenants"
|
|
:loading="loading"
|
|
striped-rows
|
|
size="small"
|
|
@row-click="onRowClick"
|
|
class="saas-table"
|
|
>
|
|
<Column field="code" header="Código" style="width: 8rem" />
|
|
<Column field="name" header="Nombre" />
|
|
<Column field="contact_email" header="Contacto" />
|
|
<Column field="plan" header="Plan" style="width: 6rem" />
|
|
<Column field="status" header="Estatus" style="width: 7rem" />
|
|
<Column field="access_sent_at" header="Acceso enviado" style="width: 10rem">
|
|
<template #body="{ data }">{{ data.access_sent_at || "—" }}</template>
|
|
</Column>
|
|
<Column header="" style="width: 4.5rem">
|
|
<template #body="{ data }">
|
|
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Editar" @click.stop="openDetail(data.id)" />
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
|
|
<Dialog
|
|
v-model:visible="showForm"
|
|
modal
|
|
header="Alta de empresa / cliente"
|
|
:style="{ width: 'min(720px, 96vw)' }"
|
|
@hide="resetForm"
|
|
>
|
|
<form class="saas-form" @submit.prevent="submit">
|
|
<p class="saas-form-hint">
|
|
Todos los campos son obligatorios. Código = nombre (4) + mes de alta; usuario = el mismo código.
|
|
</p>
|
|
|
|
<Divider align="left"><span class="text-sm">Identidad fiscal</span></Divider>
|
|
<div class="detail-grid">
|
|
<FloatLabel variant="on">
|
|
<InputText id="nombre_comercial" v-model="form.nombre_comercial" class="w-full" required />
|
|
<label for="nombre_comercial">Nombre comercial</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="razon_social" v-model="form.razon_social" class="w-full" required />
|
|
<label for="razon_social">Razón social</label>
|
|
</FloatLabel>
|
|
<p class="saas-form-hint span-2">
|
|
Código automático: <strong>{{ codePreview }}</strong>
|
|
· Usuario: <strong>{{ adminPreview }}</strong>
|
|
<span v-if="codePreviewNote"> ({{ codePreviewNote }})</span>
|
|
</p>
|
|
<FloatLabel variant="on">
|
|
<InputText id="rfc" v-model="form.rfc" class="w-full" maxlength="13" required />
|
|
<label for="rfc">RFC</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<Select
|
|
id="regimen"
|
|
v-model="form.regimen_fiscal"
|
|
:options="regimenOpts"
|
|
option-label="label"
|
|
option-value="value"
|
|
class="w-full"
|
|
required
|
|
/>
|
|
<label for="regimen">Régimen fiscal</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<Divider align="left"><span class="text-sm">Domicilio y contacto</span></Divider>
|
|
<div class="detail-grid">
|
|
<FloatLabel variant="on" class="span-2">
|
|
<InputText id="domicilio" v-model="form.domicilio_fiscal" class="w-full" required />
|
|
<label for="domicilio">Domicilio fiscal</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="cp" v-model="form.codigo_postal" class="w-full" maxlength="5" pattern="[0-9]{5}" required />
|
|
<label for="cp">Código postal</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="ciudad" v-model="form.ciudad" class="w-full" required />
|
|
<label for="ciudad">Ciudad</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="estado" v-model="form.estado" class="w-full" required />
|
|
<label for="estado">Estado</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="telefono" v-model="form.telefono" class="w-full" required />
|
|
<label for="telefono">Teléfono</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="email" v-model="form.email" class="w-full" type="email" required />
|
|
<label for="email">Email (recibe el acceso)</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="rep" v-model="form.representante_legal" class="w-full" required />
|
|
<label for="rep">Representante legal</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="contact_name" v-model="form.contact_name" class="w-full" required />
|
|
<label for="contact_name">Nombre de contacto</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<Divider align="left"><span class="text-sm">Acceso administrador</span></Divider>
|
|
<div class="check-row">
|
|
<Checkbox v-model="form.send_email" input-id="send_email" binary :disabled="!form.email" />
|
|
<label for="send_email">Enviar acceso por correo ahora</label>
|
|
</div>
|
|
<Message v-if="formError" severity="error" :closable="false">{{ formError }}</Message>
|
|
<div class="saas-form-actions">
|
|
<Button type="button" label="Cancelar" severity="secondary" text @click="showForm = false" />
|
|
<Button type="submit" label="Crear" icon="pi pi-check" :loading="saving" />
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
v-model:visible="showCreds"
|
|
modal
|
|
header="Acceso generado"
|
|
:style="{ width: 'min(480px, 94vw)' }"
|
|
:closable="true"
|
|
>
|
|
<p class="saas-form-hint">Guarde o envíe estos datos. La contraseña no se vuelve a mostrar igual (salvo regenerar).</p>
|
|
<dl class="creds">
|
|
<dt>Usuario</dt>
|
|
<dd><code>{{ issued?.username }}</code></dd>
|
|
<dt>Contraseña temporal</dt>
|
|
<dd><code>{{ issued?.temporary_password }}</code></dd>
|
|
<dt>Correo</dt>
|
|
<dd>{{ issued?.email || "—" }}</dd>
|
|
<dt>Envío</dt>
|
|
<dd>{{ emailStatus }}</dd>
|
|
</dl>
|
|
<div class="saas-form-actions">
|
|
<Button label="Copiar contraseña" icon="pi pi-copy" severity="secondary" @click="copyPass" />
|
|
<Button label="Listo" icon="pi pi-check" @click="showCreds = false" />
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
v-model:visible="showDetail"
|
|
modal
|
|
:header="detail ? `${detail.code} — ${detail.name}` : 'Empresa'"
|
|
:style="{ width: 'min(720px, 96vw)' }"
|
|
@hide="detail = null"
|
|
>
|
|
<div v-if="detailLoading" class="muted">Cargando…</div>
|
|
<form v-else-if="detail" class="saas-form" @submit.prevent="saveDetail">
|
|
<Divider align="left"><span class="text-sm">Licencia SaaS</span></Divider>
|
|
<div class="detail-grid">
|
|
<FloatLabel variant="on">
|
|
<Select id="d_status" v-model="edit.status" :options="statusOptions" class="w-full" required />
|
|
<label for="d_status">Estatus</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<Select id="d_plan" v-model="edit.plan" :options="planOptions" class="w-full" required />
|
|
<label for="d_plan">Plan</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_until" v-model="edit.valid_until" class="w-full" placeholder="YYYY-MM-DD" required />
|
|
<label for="d_until">Vigencia</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_cname" v-model="edit.contact_name" class="w-full" required />
|
|
<label for="d_cname">Contacto acceso</label>
|
|
</FloatLabel>
|
|
</div>
|
|
<FloatLabel variant="on">
|
|
<Textarea id="d_notes" v-model="edit.notes" rows="2" class="w-full" auto-resize required />
|
|
<label for="d_notes">Notas internas</label>
|
|
</FloatLabel>
|
|
|
|
<Divider align="left"><span class="text-sm">Datos fiscales</span></Divider>
|
|
<div class="detail-grid">
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_comercial" v-model="edit.nombre_comercial" class="w-full" required />
|
|
<label for="d_comercial">Nombre comercial</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_razon" v-model="edit.razon_social" class="w-full" required />
|
|
<label for="d_razon">Razón social</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_rfc" v-model="edit.rfc" class="w-full" maxlength="13" required />
|
|
<label for="d_rfc">RFC</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<Select
|
|
id="d_regimen"
|
|
v-model="edit.regimen_fiscal"
|
|
:options="regimenOpts"
|
|
option-label="label"
|
|
option-value="value"
|
|
class="w-full"
|
|
required
|
|
/>
|
|
<label for="d_regimen">Régimen fiscal</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on" class="span-2">
|
|
<InputText id="d_dom" v-model="edit.domicilio_fiscal" class="w-full" required />
|
|
<label for="d_dom">Domicilio fiscal</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_cp" v-model="edit.codigo_postal" class="w-full" maxlength="5" pattern="[0-9]{5}" required />
|
|
<label for="d_cp">C.P.</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_ciudad" v-model="edit.ciudad" class="w-full" required />
|
|
<label for="d_ciudad">Ciudad</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_estado" v-model="edit.estado" class="w-full" required />
|
|
<label for="d_estado">Estado</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_tel" v-model="edit.telefono" class="w-full" required />
|
|
<label for="d_tel">Teléfono</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_email" v-model="edit.email" class="w-full" type="email" required />
|
|
<label for="d_email">Email (recibe el acceso)</label>
|
|
</FloatLabel>
|
|
<FloatLabel variant="on">
|
|
<InputText id="d_rep" v-model="edit.representante_legal" class="w-full" required />
|
|
<label for="d_rep">Representante legal</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<Divider align="left"><span class="text-sm">Administrador del cliente</span></Divider>
|
|
<p class="saas-form-hint">Solo un admin creado por PANELS (usuario = código). Más usuarios = IAM del cliente.</p>
|
|
<ul v-if="detail.admins.length" class="admin-list">
|
|
<li v-for="a in detail.admins" :key="a.id">
|
|
<div>
|
|
<strong>{{ a.username }}</strong>
|
|
<span class="admin-meta">{{ a.display_name }} · {{ a.must_change_password ? "debe cambiar contraseña" : "activo" }}</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
label="Regenerar y enviar"
|
|
icon="pi pi-send"
|
|
size="small"
|
|
@click="sendAccess(a.id)"
|
|
:loading="accessSending"
|
|
/>
|
|
</li>
|
|
</ul>
|
|
|
|
<Message v-if="detailError" severity="error" :closable="false">{{ detailError }}</Message>
|
|
<div class="saas-form-actions">
|
|
<Button type="button" label="Cerrar" severity="secondary" text @click="showDetail = false" />
|
|
<Button type="submit" label="Guardar" icon="pi pi-check" :loading="detailSaving" />
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const { api } = useApi();
|
|
const toast = useToast();
|
|
|
|
type Tenant = {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
status: string;
|
|
created_at: string;
|
|
plan?: string;
|
|
valid_until?: string | null;
|
|
notes?: string;
|
|
contact_email?: string;
|
|
contact_name?: string;
|
|
access_sent_at?: string | null;
|
|
};
|
|
|
|
type TenantAdmin = {
|
|
id: number;
|
|
username: string;
|
|
display_name: string;
|
|
email: string;
|
|
must_change_password: boolean;
|
|
};
|
|
|
|
type CompanySnap = {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
rfc: string;
|
|
razon_social: string;
|
|
nombre_comercial: string;
|
|
regimen_fiscal: string;
|
|
domicilio_fiscal: string;
|
|
codigo_postal: string;
|
|
ciudad: string;
|
|
estado: string;
|
|
telefono: string;
|
|
email: string;
|
|
representante_legal: string;
|
|
giro: string;
|
|
};
|
|
|
|
type TenantDetail = Tenant & {
|
|
company: CompanySnap | null;
|
|
admins: TenantAdmin[];
|
|
plan: string;
|
|
valid_until: string | null;
|
|
notes: string;
|
|
contact_email: string;
|
|
contact_name: string;
|
|
access_sent_at: string | null;
|
|
};
|
|
|
|
const statusOptions = ["activo", "suspendido", "cancelado"];
|
|
const planOptions = ["trial", "basic", "pro"];
|
|
const regimenOpts = [
|
|
{ value: "601", label: "601 · General de Ley Personas Morales" },
|
|
{ value: "603", label: "603 · Personas Morales con Fines no Lucrativos" },
|
|
{ value: "605", label: "605 · Sueldos y Salarios" },
|
|
{ value: "606", label: "606 · Arrendamiento" },
|
|
{ value: "612", label: "612 · Actividades Empresariales y Profesionales" },
|
|
{ value: "621", label: "621 · Incorporación Fiscal" },
|
|
{ value: "625", label: "625 · Plataformas Tecnológicas" },
|
|
{ value: "626", label: "626 · Régimen Simplificado de Confianza" },
|
|
];
|
|
|
|
function emptyCompanyForm() {
|
|
return {
|
|
nombre_comercial: "",
|
|
razon_social: "",
|
|
rfc: "",
|
|
regimen_fiscal: "601",
|
|
domicilio_fiscal: "",
|
|
codigo_postal: "",
|
|
ciudad: "",
|
|
estado: "",
|
|
telefono: "",
|
|
email: "",
|
|
representante_legal: "",
|
|
contact_name: "",
|
|
send_email: true,
|
|
};
|
|
}
|
|
|
|
const tenants = ref<Tenant[]>([]);
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const smtpOk = ref(true);
|
|
|
|
const showForm = ref(false);
|
|
const saving = ref(false);
|
|
const formError = ref("");
|
|
const form = reactive(emptyCompanyForm());
|
|
|
|
function yymmNow() {
|
|
const d = new Date();
|
|
return `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
const codePreview = computed(() => {
|
|
const slug = (form.nombre_comercial || "")
|
|
.normalize("NFD")
|
|
.replace(/\p{M}/gu, "")
|
|
.toUpperCase()
|
|
.replace(/[^A-Z0-9]+/g, "")
|
|
.slice(0, 4);
|
|
if (slug.length < 2) return "—";
|
|
return `${slug}${yymmNow()}`;
|
|
});
|
|
|
|
const codePreviewNote = computed(() => {
|
|
if (codePreview.value === "—") return "escribe el nombre comercial";
|
|
const taken = tenants.value.some((t) => t.code === codePreview.value);
|
|
return taken ? "si ya existe se añadirá un número" : "nombre + mes de alta";
|
|
});
|
|
|
|
const adminPreview = computed(() =>
|
|
codePreview.value === "—" ? "…" : codePreview.value.toLowerCase(),
|
|
);
|
|
|
|
const showCreds = ref(false);
|
|
const issued = ref<{ username: string; temporary_password: string; email: string } | null>(null);
|
|
const emailStatus = ref("");
|
|
|
|
const showDetail = ref(false);
|
|
const detailLoading = ref(false);
|
|
const detailSaving = ref(false);
|
|
const detailError = ref("");
|
|
const detail = ref<TenantDetail | null>(null);
|
|
const edit = reactive({
|
|
status: "activo",
|
|
plan: "trial",
|
|
valid_until: "",
|
|
notes: "",
|
|
contact_name: "",
|
|
...emptyCompanyForm(),
|
|
});
|
|
const accessSending = ref(false);
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const r = await api<{ tenants: Tenant[]; smtp_configured?: boolean }>("/v1/saas/tenants");
|
|
tenants.value = r.tenants;
|
|
smtpOk.value = !!r.smtp_configured;
|
|
} catch (e: unknown) {
|
|
error.value = e instanceof Error ? e.message : "No se pudo cargar";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
Object.assign(form, emptyCompanyForm());
|
|
formError.value = "";
|
|
showForm.value = true;
|
|
}
|
|
|
|
function resetForm() {
|
|
formError.value = "";
|
|
}
|
|
|
|
function requireFilled(fields: [string, unknown][]): string | null {
|
|
for (const [label, value] of fields) {
|
|
if (!String(value ?? "").trim()) return `${label} obligatorio`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function submit() {
|
|
formError.value = "";
|
|
const missing = requireFilled([
|
|
["Nombre comercial", form.nombre_comercial],
|
|
["Razón social", form.razon_social],
|
|
["RFC", form.rfc],
|
|
["Régimen fiscal", form.regimen_fiscal],
|
|
["Domicilio fiscal", form.domicilio_fiscal],
|
|
["Código postal", form.codigo_postal],
|
|
["Ciudad", form.ciudad],
|
|
["Estado", form.estado],
|
|
["Teléfono", form.telefono],
|
|
["Email", form.email],
|
|
["Representante legal", form.representante_legal],
|
|
["Nombre de contacto", form.contact_name],
|
|
]);
|
|
if (missing) {
|
|
formError.value = missing;
|
|
return;
|
|
}
|
|
if (!/^\d{5}$/.test(form.codigo_postal.trim())) {
|
|
formError.value = "Código postal debe ser 5 dígitos";
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
try {
|
|
const r = await api<{
|
|
tenant: Tenant;
|
|
admin: { username: string; temporary_password: string; email: string };
|
|
email?: { sent: boolean; error?: string };
|
|
}>("/v1/saas/tenants", {
|
|
method: "POST",
|
|
json: {
|
|
nombre_comercial: form.nombre_comercial,
|
|
razon_social: form.razon_social,
|
|
rfc: form.rfc,
|
|
regimen_fiscal: form.regimen_fiscal,
|
|
domicilio_fiscal: form.domicilio_fiscal,
|
|
codigo_postal: form.codigo_postal,
|
|
ciudad: form.ciudad,
|
|
estado: form.estado,
|
|
telefono: form.telefono,
|
|
email: form.email,
|
|
representante_legal: form.representante_legal,
|
|
contact_email: form.email,
|
|
contact_name: form.contact_name,
|
|
send_email: form.send_email && !!form.email,
|
|
},
|
|
});
|
|
showForm.value = false;
|
|
issued.value = r.admin;
|
|
if (r.email?.sent) emailStatus.value = `Correo enviado a ${r.admin.email} (revise spam)`;
|
|
else if (r.email?.error) emailStatus.value = `No enviado: ${r.email.error}`;
|
|
else emailStatus.value = "No enviado (copie la contraseña y envíela al cliente)";
|
|
showCreds.value = true;
|
|
toast.add({ severity: "success", summary: "Empresa creada", life: 2500 });
|
|
await load();
|
|
} catch (e: unknown) {
|
|
formError.value = e instanceof Error ? e.message : "Error al crear";
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
function onRowClick(e: { data: Tenant }) {
|
|
openDetail(e.data.id);
|
|
}
|
|
|
|
async function openDetail(id: number) {
|
|
showDetail.value = true;
|
|
detailLoading.value = true;
|
|
detailError.value = "";
|
|
detail.value = null;
|
|
try {
|
|
const r = await api<{ tenant: TenantDetail; smtp_configured?: boolean }>(`/v1/saas/tenants/${id}`);
|
|
detail.value = r.tenant;
|
|
smtpOk.value = r.smtp_configured ?? smtpOk.value;
|
|
const c = r.tenant.company;
|
|
edit.status = r.tenant.status;
|
|
edit.plan = r.tenant.plan || "trial";
|
|
edit.valid_until = r.tenant.valid_until || "";
|
|
edit.notes = r.tenant.notes || "";
|
|
edit.contact_name = r.tenant.contact_name || "";
|
|
edit.nombre_comercial = c?.nombre_comercial || r.tenant.name || "";
|
|
edit.razon_social = c?.razon_social || "";
|
|
edit.rfc = c?.rfc || "";
|
|
edit.regimen_fiscal = c?.regimen_fiscal || "601";
|
|
edit.domicilio_fiscal = c?.domicilio_fiscal || "";
|
|
edit.codigo_postal = c?.codigo_postal || "";
|
|
edit.ciudad = c?.ciudad || "";
|
|
edit.estado = c?.estado || "";
|
|
edit.telefono = c?.telefono || "";
|
|
edit.email = c?.email || r.tenant.contact_email || "";
|
|
edit.representante_legal = c?.representante_legal || "";
|
|
} catch (e: unknown) {
|
|
detailError.value = e instanceof Error ? e.message : "No se pudo abrir";
|
|
} finally {
|
|
detailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveDetail() {
|
|
if (!detail.value) return;
|
|
detailError.value = "";
|
|
const missing = requireFilled([
|
|
["Estatus", edit.status],
|
|
["Plan", edit.plan],
|
|
["Vigencia", edit.valid_until],
|
|
["Contacto acceso", edit.contact_name],
|
|
["Notas internas", edit.notes],
|
|
["Nombre comercial", edit.nombre_comercial],
|
|
["Razón social", edit.razon_social],
|
|
["RFC", edit.rfc],
|
|
["Régimen fiscal", edit.regimen_fiscal],
|
|
["Domicilio fiscal", edit.domicilio_fiscal],
|
|
["C.P.", edit.codigo_postal],
|
|
["Ciudad", edit.ciudad],
|
|
["Estado", edit.estado],
|
|
["Teléfono", edit.telefono],
|
|
["Email", edit.email],
|
|
["Representante legal", edit.representante_legal],
|
|
]);
|
|
if (missing) {
|
|
detailError.value = missing;
|
|
return;
|
|
}
|
|
if (!/^\d{5}$/.test(String(edit.codigo_postal).trim())) {
|
|
detailError.value = "Código postal debe ser 5 dígitos";
|
|
return;
|
|
}
|
|
detailSaving.value = true;
|
|
try {
|
|
const r = await api<{ tenant: TenantDetail }>(`/v1/saas/tenants/${detail.value.id}`, {
|
|
method: "PATCH",
|
|
json: {
|
|
status: edit.status,
|
|
plan: edit.plan,
|
|
valid_until: edit.valid_until,
|
|
notes: edit.notes,
|
|
contact_name: edit.contact_name,
|
|
contact_email: edit.email,
|
|
nombre_comercial: edit.nombre_comercial,
|
|
razon_social: edit.razon_social,
|
|
rfc: edit.rfc,
|
|
regimen_fiscal: edit.regimen_fiscal,
|
|
domicilio_fiscal: edit.domicilio_fiscal,
|
|
codigo_postal: edit.codigo_postal,
|
|
ciudad: edit.ciudad,
|
|
estado: edit.estado,
|
|
telefono: edit.telefono,
|
|
email: edit.email,
|
|
representante_legal: edit.representante_legal,
|
|
},
|
|
});
|
|
detail.value = r.tenant;
|
|
toast.add({ severity: "success", summary: "Cambios guardados", life: 2200 });
|
|
await load();
|
|
} catch (e: unknown) {
|
|
detailError.value = e instanceof Error ? e.message : "Error al guardar";
|
|
} finally {
|
|
detailSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function sendAccess(userId: number) {
|
|
if (!detail.value) return;
|
|
accessSending.value = true;
|
|
try {
|
|
const r = await api<{
|
|
admin: { username: string; temporary_password: string; email: string };
|
|
email?: { sent: boolean; error?: string };
|
|
}>(`/v1/saas/tenants/${detail.value.id}/send-access`, {
|
|
method: "POST",
|
|
json: { user_id: userId, send_email: true },
|
|
});
|
|
issued.value = r.admin;
|
|
if (r.email?.sent) emailStatus.value = `Correo enviado a ${r.admin.email} (revise spam)`;
|
|
else if (r.email?.error) emailStatus.value = `No enviado: ${r.email.error}`;
|
|
else emailStatus.value = "No enviado";
|
|
showCreds.value = true;
|
|
await openDetail(detail.value.id);
|
|
await load();
|
|
} catch (e: unknown) {
|
|
toast.add({
|
|
severity: "error",
|
|
summary: e instanceof Error ? e.message : "Error",
|
|
life: 3000,
|
|
});
|
|
} finally {
|
|
accessSending.value = false;
|
|
}
|
|
}
|
|
|
|
async function copyPass() {
|
|
if (!issued.value?.temporary_password) return;
|
|
await navigator.clipboard.writeText(issued.value.temporary_password);
|
|
toast.add({ severity: "info", summary: "Contraseña copiada", life: 1800 });
|
|
}
|
|
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.saas-page {
|
|
max-width: 1080px;
|
|
margin: 0 auto;
|
|
padding: 1.5rem 1.25rem 2.5rem;
|
|
}
|
|
.saas-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
.saas-kicker {
|
|
margin: 0;
|
|
font-size: 0.75rem;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: #6b7280;
|
|
}
|
|
.saas-header h1 {
|
|
margin: 0.2rem 0 0.35rem;
|
|
font-size: 1.6rem;
|
|
}
|
|
.saas-sub {
|
|
margin: 0;
|
|
color: #6b7280;
|
|
}
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.smtp-link {
|
|
margin-left: 0.35rem;
|
|
color: #0f4c81;
|
|
font-weight: 600;
|
|
text-decoration: underline;
|
|
}
|
|
.saas-table :deep(.p-datatable-tbody > tr) {
|
|
cursor: pointer;
|
|
}
|
|
.saas-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.1rem;
|
|
}
|
|
.saas-form-hint {
|
|
margin: 0;
|
|
font-size: 0.9rem;
|
|
color: #6b7280;
|
|
}
|
|
.saas-form-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 0.25rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.check-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
}
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.1rem;
|
|
}
|
|
.detail-grid :deep(.span-2),
|
|
.span-2 {
|
|
grid-column: 1 / -1;
|
|
}
|
|
@media (max-width: 560px) {
|
|
.detail-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
.admin-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.65rem;
|
|
}
|
|
.admin-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 0.65rem 0.75rem;
|
|
background: #fff;
|
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
border-radius: 8px;
|
|
}
|
|
.admin-meta {
|
|
display: block;
|
|
font-size: 0.85rem;
|
|
color: #64748b;
|
|
}
|
|
.creds {
|
|
display: grid;
|
|
grid-template-columns: 9rem 1fr;
|
|
gap: 0.45rem 0.75rem;
|
|
margin: 0.5rem 0 1rem;
|
|
}
|
|
.creds dt {
|
|
color: #64748b;
|
|
font-size: 0.85rem;
|
|
}
|
|
.creds dd {
|
|
margin: 0;
|
|
}
|
|
.creds code {
|
|
font-size: 0.95rem;
|
|
word-break: break-all;
|
|
}
|
|
.muted {
|
|
color: #64748b;
|
|
padding: 1rem 0;
|
|
}
|
|
</style>
|