feat(fin): pantallas de conceptos y datos fiscales del emisor
Clientes API por dominio para los catálogos del SAT, conceptos y emisor. Los catálogos se cachean en un Map del módulo tras la primera carga: son fijos y no cambian durante la sesión. Pantalla de conceptos (/dashboard/fin/conceptos) con tabla, buscador, filtro de activos y alta/edición en diálogo. La clave de producto/servicio se elige con un combobox que consulta el catálogo a partir de 2 caracteres, y el 409 del backend por clave ya asignada se muestra junto al campo. Sección de configuración fiscal (/dashboard/settings/facturacion) con razón social, RFC (misma validación que el backend), régimen fiscal y CP. Si el GET responde 404 se abre en modo alta, no como error; el guardar se deshabilita sin fin.settings.edit. Todo en Svelte 5 con runes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
frontend/src/lib/api/fin/catalogs.ts
Normal file
94
frontend/src/lib/api/fin/catalogs.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Catálogos del SAT (solo lectura).
|
||||||
|
*
|
||||||
|
* Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la
|
||||||
|
* sesión, así que se guardan en un `Map` del módulo para no repetir la petición en
|
||||||
|
* cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone.
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export interface SatCatalogItem {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatTaxRegime extends SatCatalogItem {
|
||||||
|
applies_to_individual: boolean; // persona física
|
||||||
|
applies_to_legal_entity: boolean; // persona moral
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatTax extends SatCatalogItem {
|
||||||
|
is_withholding: boolean;
|
||||||
|
is_transferred: boolean;
|
||||||
|
is_local: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */
|
||||||
|
export interface SatUnitOfMeasure extends Omit<SatCatalogItem, 'description'> {
|
||||||
|
description: string | null;
|
||||||
|
name: string;
|
||||||
|
symbol: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonType = 'fisica' | 'moral';
|
||||||
|
|
||||||
|
type CatalogParams = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
/** Cache en memoria del módulo, con la query string completa como llave. */
|
||||||
|
const cache = new Map<string, unknown>();
|
||||||
|
|
||||||
|
function buildQuery(companyId: number, params?: CatalogParams): string {
|
||||||
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||||
|
for (const [key, value] of Object.entries(params ?? {})) {
|
||||||
|
if (value !== undefined && value !== '') qs.set(key, String(value));
|
||||||
|
}
|
||||||
|
qs.sort(); // llave de cache estable sin importar el orden de los parámetros
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCatalog<T>(
|
||||||
|
path: string,
|
||||||
|
companyId: number,
|
||||||
|
params?: CatalogParams
|
||||||
|
): Promise<T[]> {
|
||||||
|
const query = buildQuery(companyId, params);
|
||||||
|
const key = `${path}?${query}`;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) return cached as T[];
|
||||||
|
|
||||||
|
const res = await api.get<T[]>(`/v1/fin/catalogs/${path}?${query}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
const rows = res.data ?? [];
|
||||||
|
cache.set(key, rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */
|
||||||
|
export function clearCatalogCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const satCatalogsAPI = {
|
||||||
|
taxRegimes: (
|
||||||
|
companyId: number,
|
||||||
|
params?: { search?: string; person_type?: PersonType; active_only?: boolean }
|
||||||
|
) => fetchCatalog<SatTaxRegime>('tax-regimes', companyId, params),
|
||||||
|
taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatTax>('taxes', companyId, params),
|
||||||
|
paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('payment-forms', companyId, params),
|
||||||
|
unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatUnitOfMeasure>('units-of-measure', companyId, params),
|
||||||
|
productsServices: (
|
||||||
|
companyId: number,
|
||||||
|
params?: { search?: string; limit?: number; active_only?: boolean }
|
||||||
|
) => fetchCatalog<SatCatalogItem>('products-services', companyId, params),
|
||||||
|
voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('voucher-types', companyId, params),
|
||||||
|
paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('payment-methods', companyId, params),
|
||||||
|
taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('tax-objects', companyId, params)
|
||||||
|
};
|
||||||
81
frontend/src/lib/api/fin/concepts.ts
Normal file
81
frontend/src/lib/api/fin/concepts.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Catálogo de conceptos de facturación.
|
||||||
|
*
|
||||||
|
* Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la
|
||||||
|
* empresa; el backend responde 409 si la clave ya está tomada.
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs';
|
||||||
|
|
||||||
|
export interface Concept {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
product_service_id: number;
|
||||||
|
unit_of_measure_id: number | null;
|
||||||
|
tax_object_id: number | null;
|
||||||
|
unit_price: number | null;
|
||||||
|
currency: string;
|
||||||
|
is_active: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
product_service: SatCatalogItem | null;
|
||||||
|
unit_of_measure: SatUnitOfMeasure | null;
|
||||||
|
tax_object: SatCatalogItem | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConceptInput {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
product_service_id: number;
|
||||||
|
unit_of_measure_id?: number | null;
|
||||||
|
tax_object_id?: number | null;
|
||||||
|
unit_price?: number | null;
|
||||||
|
currency?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conceptsAPI = {
|
||||||
|
async list(
|
||||||
|
companyId: number,
|
||||||
|
params?: { search?: string; active_only?: boolean; product_service_id?: number }
|
||||||
|
): Promise<Concept[]> {
|
||||||
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only));
|
||||||
|
if (params?.product_service_id !== undefined)
|
||||||
|
qs.set('product_service_id', String(params.product_service_id));
|
||||||
|
const res = await api.get<Concept[]>(`/v1/fin/concepts?${qs}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: number, companyId: number): Promise<Concept> {
|
||||||
|
const res = await api.get<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: ConceptInput, companyId: number): Promise<Concept> {
|
||||||
|
const res = await api.post<Concept>(`/v1/fin/concepts?company_id=${companyId}`, data);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, data: Partial<ConceptInput>, companyId: number): Promise<Concept> {
|
||||||
|
const res = await api.patch<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`, data);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: number, companyId: number): Promise<void> {
|
||||||
|
const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export * from './catalogs';
|
||||||
|
export * from './concepts';
|
||||||
|
export * from './issuer';
|
||||||
|
|
||||||
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
|
|||||||
51
frontend/src/lib/api/fin/issuer.ts
Normal file
51
frontend/src/lib/api/fin/issuer.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Datos fiscales del emisor (una configuración por empresa).
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { SatTaxRegime } from './catalogs';
|
||||||
|
|
||||||
|
/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */
|
||||||
|
export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||||
|
|
||||||
|
export interface IssuerSettings {
|
||||||
|
id: number;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
tax_regime_id: number;
|
||||||
|
tax_regime: SatTaxRegime | null;
|
||||||
|
zip_code: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuerSettingsInput {
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
tax_regime_id: number;
|
||||||
|
zip_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const issuerAPI = {
|
||||||
|
/**
|
||||||
|
* Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el
|
||||||
|
* backend responde 404 y la pantalla debe abrirse en modo alta, no en error.
|
||||||
|
*/
|
||||||
|
async get(companyId: number): Promise<IssuerSettings | null> {
|
||||||
|
const res = await api.get<IssuerSettings>(`/v1/fin/settings/issuer?company_id=${companyId}`);
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(data: IssuerSettingsInput, companyId: number): Promise<IssuerSettings> {
|
||||||
|
const res = await api.put<IssuerSettings>(
|
||||||
|
`/v1/fin/settings/issuer?company_id=${companyId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -67,6 +67,7 @@ export function getNavMain(): NavMainItem[] {
|
|||||||
icon: Receipt,
|
icon: Receipt,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
||||||
|
{ title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,6 +84,10 @@ export function getNavMain(): NavMainItem[] {
|
|||||||
title: 'Configuración',
|
title: 'Configuración',
|
||||||
url: '/dashboard/settings/general',
|
url: '/dashboard/settings/general',
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
|
items: [
|
||||||
|
{ title: 'General', url: '/dashboard/settings/general' },
|
||||||
|
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
458
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
458
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tags, Plus, Pencil, Trash2, Search } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import {
|
||||||
|
conceptsAPI,
|
||||||
|
satCatalogsAPI,
|
||||||
|
type Concept,
|
||||||
|
type ConceptInput,
|
||||||
|
type SatCatalogItem,
|
||||||
|
type SatUnitOfMeasure
|
||||||
|
} from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const EMPTY_FORM: ConceptInput = {
|
||||||
|
code: '',
|
||||||
|
description: '',
|
||||||
|
product_service_id: 0,
|
||||||
|
unit_of_measure_id: null,
|
||||||
|
tax_object_id: null,
|
||||||
|
unit_price: null,
|
||||||
|
currency: 'MXN',
|
||||||
|
is_active: true,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = $state<Concept[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
let activeFilter = $state<'todos' | 'activos' | 'inactivos'>('todos');
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let form = $state<ConceptInput>({ ...EMPTY_FORM });
|
||||||
|
/** Error del 409 del backend, mostrado junto al campo de clave ProdServ. */
|
||||||
|
let productServiceError = $state('');
|
||||||
|
|
||||||
|
// Catálogos del SAT para los selectores.
|
||||||
|
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
|
||||||
|
let taxObjects = $state<SatCatalogItem[]>([]);
|
||||||
|
|
||||||
|
// Combobox de clave de producto/servicio.
|
||||||
|
let productServiceQuery = $state('');
|
||||||
|
let productServiceOptions = $state<SatCatalogItem[]>([]);
|
||||||
|
let productServiceSelected = $state<SatCatalogItem | null>(null);
|
||||||
|
let searchingProductService = $state(false);
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void load(cid);
|
||||||
|
void loadCatalogs(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
items = await conceptsAPI.list(cid, {
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
active_only: activeFilter === 'todos' ? undefined : activeFilter === 'activos'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los conceptos');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalogs(cid: number) {
|
||||||
|
try {
|
||||||
|
[unitsOfMeasure, taxObjects] = await Promise.all([
|
||||||
|
satCatalogsAPI.unitsOfMeasure(cid),
|
||||||
|
satCatalogsAPI.taxObjects(cid)
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca claves ProdServ; a partir de 2 caracteres para no traer el catálogo completo. */
|
||||||
|
async function searchProductServices() {
|
||||||
|
const cid = companyId;
|
||||||
|
const term = productServiceQuery.trim();
|
||||||
|
if (!cid || term.length < 2) {
|
||||||
|
productServiceOptions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchingProductService = true;
|
||||||
|
try {
|
||||||
|
productServiceOptions = await satCatalogsAPI.productsServices(cid, {
|
||||||
|
search: term,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : 'No se pudo buscar la clave de producto/servicio'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
searchingProductService = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickProductService(option: SatCatalogItem) {
|
||||||
|
productServiceSelected = option;
|
||||||
|
form.product_service_id = option.id;
|
||||||
|
productServiceQuery = '';
|
||||||
|
productServiceOptions = [];
|
||||||
|
productServiceError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId = null;
|
||||||
|
form = { ...EMPTY_FORM };
|
||||||
|
productServiceSelected = null;
|
||||||
|
productServiceQuery = '';
|
||||||
|
productServiceOptions = [];
|
||||||
|
productServiceError = '';
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(concept: Concept) {
|
||||||
|
editingId = concept.id;
|
||||||
|
form = {
|
||||||
|
code: concept.code,
|
||||||
|
description: concept.description,
|
||||||
|
product_service_id: concept.product_service_id,
|
||||||
|
unit_of_measure_id: concept.unit_of_measure_id,
|
||||||
|
tax_object_id: concept.tax_object_id,
|
||||||
|
unit_price: concept.unit_price,
|
||||||
|
currency: concept.currency,
|
||||||
|
is_active: concept.is_active,
|
||||||
|
notes: concept.notes ?? ''
|
||||||
|
};
|
||||||
|
productServiceSelected = concept.product_service;
|
||||||
|
productServiceQuery = '';
|
||||||
|
productServiceOptions = [];
|
||||||
|
productServiceError = '';
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
if (!form.code.trim() || !form.description.trim()) {
|
||||||
|
toast.error('La clave y la descripción son obligatorias');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.product_service_id) {
|
||||||
|
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
productServiceError = '';
|
||||||
|
try {
|
||||||
|
const payload: ConceptInput = {
|
||||||
|
...form,
|
||||||
|
unit_price:
|
||||||
|
form.unit_price === null || form.unit_price === undefined
|
||||||
|
? null
|
||||||
|
: Number(form.unit_price),
|
||||||
|
notes: form.notes?.trim() ? form.notes : null
|
||||||
|
};
|
||||||
|
if (editingId) {
|
||||||
|
await conceptsAPI.update(editingId, payload, cid);
|
||||||
|
toast.success('Concepto actualizado');
|
||||||
|
} else {
|
||||||
|
await conceptsAPI.create(payload, cid);
|
||||||
|
toast.success('Concepto creado');
|
||||||
|
}
|
||||||
|
modalOpen = false;
|
||||||
|
await load(cid);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'No se pudo guardar el concepto';
|
||||||
|
// El 409 del backend por clave ProdServ ya tomada se muestra junto al campo.
|
||||||
|
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
|
||||||
|
else toast.error(message);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(concept: Concept) {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
|
||||||
|
try {
|
||||||
|
await conceptsAPI.remove(concept.id, cid);
|
||||||
|
toast.success('Concepto dado de baja');
|
||||||
|
await load(cid);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number | null): string {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
return new Intl.NumberFormat('es-MX', { minimumFractionDigits: 2 }).format(Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Conceptos de facturación</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Tags class="h-6 w-6" />
|
||||||
|
Conceptos de facturación
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la
|
||||||
|
empresa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onclick={openCreate} disabled={!companyId}>
|
||||||
|
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative max-w-sm flex-1">
|
||||||
|
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
class="w-full py-2 pr-3 pl-8 {inputCls}"
|
||||||
|
placeholder="Buscar por clave o descripción…"
|
||||||
|
bind:value={search}
|
||||||
|
onchange={() => companyId && load(companyId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="{inputCls} max-w-xs"
|
||||||
|
bind:value={activeFilter}
|
||||||
|
onchange={() => companyId && load(companyId)}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="activos">Solo activos</option>
|
||||||
|
<option value="inactivos">Solo inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Sin conceptos registrados.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Clave</Table.Head>
|
||||||
|
<Table.Head>Descripción</Table.Head>
|
||||||
|
<Table.Head>Clave ProdServ</Table.Head>
|
||||||
|
<Table.Head>Unidad</Table.Head>
|
||||||
|
<Table.Head>Objeto de impuesto</Table.Head>
|
||||||
|
<Table.Head class="text-right">Precio unitario</Table.Head>
|
||||||
|
<Table.Head>Estado</Table.Head>
|
||||||
|
<Table.Head class="text-right">Acciones</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items as concept (concept.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-xs font-medium">{concept.code}</Table.Cell>
|
||||||
|
<Table.Cell>{concept.description}</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">
|
||||||
|
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
|
||||||
|
{#if concept.product_service}
|
||||||
|
<span class="block text-muted-foreground"
|
||||||
|
>{concept.product_service.description}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">{concept.unit_of_measure?.name ?? '—'}</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">{concept.tax_object?.code ?? '—'}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right"
|
||||||
|
>{money(concept.unit_price)} {concept.currency}</Table.Cell
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||||
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}"
|
||||||
|
>
|
||||||
|
{concept.is_active ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => openEdit(concept)}
|
||||||
|
aria-label="Editar"
|
||||||
|
>
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => remove(concept)}
|
||||||
|
aria-label="Dar de baja"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (modalOpen && e.key === 'Escape') modalOpen = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if modalOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="presentation"
|
||||||
|
onclick={() => (modalOpen = false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={editingId ? 'Editar concepto' : 'Nuevo concepto'}
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 text-lg font-semibold">{editingId ? 'Editar concepto' : 'Nuevo concepto'}</h2>
|
||||||
|
<form class="grid gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Clave *</span>
|
||||||
|
<input class={inputCls} bind:value={form.code} maxlength="40" required />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Precio unitario</span>
|
||||||
|
<input type="number" step="0.01" min="0" class={inputCls} bind:value={form.unit_price} />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Descripción *</span>
|
||||||
|
<input class={inputCls} bind:value={form.description} maxlength="500" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Clave de producto/servicio del SAT *</span>
|
||||||
|
{#if productServiceSelected}
|
||||||
|
<div class="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
|
||||||
|
<span class="text-sm">
|
||||||
|
<span class="font-mono">{productServiceSelected.code}</span>
|
||||||
|
<span class="text-muted-foreground"> — {productServiceSelected.description}</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
productServiceSelected = null;
|
||||||
|
form.product_service_id = 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cambiar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
placeholder="Escribe al menos 2 caracteres (clave o descripción)…"
|
||||||
|
bind:value={productServiceQuery}
|
||||||
|
oninput={searchProductServices}
|
||||||
|
/>
|
||||||
|
{#if searchingProductService}
|
||||||
|
<p class="text-xs text-muted-foreground">Buscando…</p>
|
||||||
|
{:else if productServiceOptions.length > 0}
|
||||||
|
<ul class="max-h-48 overflow-y-auto rounded-md border">
|
||||||
|
{#each productServiceOptions as option (option.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-muted"
|
||||||
|
onclick={() => pickProductService(option)}
|
||||||
|
>
|
||||||
|
<span class="font-mono">{option.code}</span>
|
||||||
|
<span class="text-muted-foreground"> — {option.description}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if productServiceError}
|
||||||
|
<p class="text-xs text-destructive">{productServiceError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Unidad de medida</span>
|
||||||
|
<select class={inputCls} bind:value={form.unit_of_measure_id}>
|
||||||
|
<option value={null}>Sin especificar</option>
|
||||||
|
{#each unitsOfMeasure as unit (unit.id)}
|
||||||
|
<option value={unit.id}>{unit.code} — {unit.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Objeto de impuesto</span>
|
||||||
|
<select class={inputCls} bind:value={form.tax_object_id}>
|
||||||
|
<option value={null}>Sin especificar</option>
|
||||||
|
{#each taxObjects as taxObject (taxObject.id)}
|
||||||
|
<option value={taxObject.id}>{taxObject.code} — {taxObject.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Moneda</span>
|
||||||
|
<input class={inputCls} bind:value={form.currency} maxlength="3" />
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 self-end text-sm">
|
||||||
|
<input type="checkbox" bind:checked={form.is_active} />
|
||||||
|
<span class="font-medium">Activo</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Notas</span>
|
||||||
|
<textarea rows="2" class={inputCls} bind:value={form.notes}></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 sm:col-span-2">
|
||||||
|
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}
|
||||||
|
>Cancelar</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Receipt } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { authStore, userHasPermission } from '$lib/auth';
|
||||||
|
import {
|
||||||
|
issuerAPI,
|
||||||
|
satCatalogsAPI,
|
||||||
|
RFC_REGEX,
|
||||||
|
type IssuerSettingsInput,
|
||||||
|
type SatTaxRegime
|
||||||
|
} from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let form = $state<IssuerSettingsInput>({
|
||||||
|
legal_name: '',
|
||||||
|
rfc: '',
|
||||||
|
tax_regime_id: 0,
|
||||||
|
zip_code: ''
|
||||||
|
});
|
||||||
|
let taxRegimes = $state<SatTaxRegime[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
/** true mientras la empresa no tenga datos capturados (el GET respondió 404). */
|
||||||
|
let isNew = $state(true);
|
||||||
|
let rfcError = $state('');
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
const canView = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||||
|
const canEdit = $derived(userHasPermission($authStore.user, 'fin.settings.edit'));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid || !canView) return;
|
||||||
|
void load(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const [settings, regimes] = await Promise.all([
|
||||||
|
issuerAPI.get(cid),
|
||||||
|
satCatalogsAPI.taxRegimes(cid)
|
||||||
|
]);
|
||||||
|
taxRegimes = regimes;
|
||||||
|
isNew = settings === null;
|
||||||
|
if (settings) {
|
||||||
|
form = {
|
||||||
|
legal_name: settings.legal_name,
|
||||||
|
rfc: settings.rfc,
|
||||||
|
tax_regime_id: settings.tax_regime_id,
|
||||||
|
zip_code: settings.zip_code ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos fiscales');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedRfc(): string {
|
||||||
|
return (form.rfc ?? '').replace(/[\s-]/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
|
||||||
|
const rfc = normalizedRfc();
|
||||||
|
if (!RFC_REGEX.test(rfc)) {
|
||||||
|
rfcError = 'El RFC no tiene un formato válido (ej. XAXX010101000)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rfcError = '';
|
||||||
|
if (!form.tax_regime_id) {
|
||||||
|
toast.error('Selecciona el régimen fiscal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await issuerAPI.save(
|
||||||
|
{ ...form, rfc, zip_code: form.zip_code?.trim() ? form.zip_code.trim() : null },
|
||||||
|
cid
|
||||||
|
);
|
||||||
|
isNew = false;
|
||||||
|
toast.success('Datos fiscales guardados');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los datos fiscales');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Configuración de Facturación</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Receipt class="h-6 w-6" />
|
||||||
|
Datos fiscales del emisor
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Identidad fiscal con la que la empresa emite sus comprobantes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !canView}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No tienes permiso para consultar los datos fiscales del emisor.
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>{isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'}</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
{isNew
|
||||||
|
? 'Esta empresa aún no tiene datos fiscales configurados.'
|
||||||
|
: 'Actualiza la información con la que se emiten los comprobantes.'}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else}
|
||||||
|
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Razón social *</span>
|
||||||
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
bind:value={form.legal_name}
|
||||||
|
maxlength="255"
|
||||||
|
required
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">RFC *</span>
|
||||||
|
<input
|
||||||
|
class="{inputCls} font-mono uppercase"
|
||||||
|
bind:value={form.rfc}
|
||||||
|
maxlength="13"
|
||||||
|
required
|
||||||
|
disabled={!canEdit}
|
||||||
|
oninput={() => (rfcError = '')}
|
||||||
|
/>
|
||||||
|
{#if rfcError}<span class="text-xs text-destructive">{rfcError}</span>{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Código postal del lugar de expedición</span>
|
||||||
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
bind:value={form.zip_code}
|
||||||
|
maxlength="5"
|
||||||
|
inputmode="numeric"
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Régimen fiscal *</span>
|
||||||
|
<select class={inputCls} bind:value={form.tax_regime_id} required disabled={!canEdit}>
|
||||||
|
<option value={0}>Selecciona un régimen…</option>
|
||||||
|
{#each taxRegimes as regime (regime.id)}
|
||||||
|
<option value={regime.id}>{regime.code} — {regime.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end sm:col-span-2">
|
||||||
|
<Button type="submit" disabled={saving || !canEdit || !companyId}>
|
||||||
|
{saving ? 'Guardando…' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if !canEdit}
|
||||||
|
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||||
|
Solo puedes consultar: se requiere el permiso de edición de datos fiscales.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings2 } from 'lucide-svelte';
|
import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { authStore, userHasPermission } from '$lib/auth';
|
||||||
|
|
||||||
|
const canViewIssuerSettings = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -18,6 +22,25 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if canViewIssuerSettings}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Receipt class="h-5 w-5" />
|
||||||
|
Facturación
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<Button variant="outline" href="/dashboard/settings/facturacion">
|
||||||
|
Abrir datos fiscales <ChevronRight class="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Configuración del sistema</Card.Title>
|
<Card.Title>Configuración del sistema</Card.Title>
|
||||||
|
|||||||
Reference in New Issue
Block a user