Facturas de cliente
This commit is contained in:
parent
747d11a956
commit
169de55b0a
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -33,7 +33,7 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
|
||||
@ -173,7 +173,7 @@
|
||||
"noThisInStatic": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessConstructor": "error",
|
||||
"noUselessFragments": "error",
|
||||
"noUselessFragments": "off",
|
||||
"noUselessLabel": "error",
|
||||
"noUselessRename": "error",
|
||||
"noUselessSwitchCase": "error",
|
||||
@ -184,7 +184,7 @@
|
||||
"useLiteralKeys": "error",
|
||||
"useOptionalChain": "error",
|
||||
"useSimpleNumberKeys": "error",
|
||||
"useSimplifiedLogicExpression": "error"
|
||||
"useSimplifiedLogicExpression": "info"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "error",
|
||||
|
||||
@ -7,7 +7,7 @@ export function mockUser(req: RequestWithAuth, _res: Response, next: NextFunctio
|
||||
req.user = {
|
||||
userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
|
||||
email: EmailAddress.create("dev@example.com").data,
|
||||
companyId: UniqueID.create("5e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
|
||||
companyId: UniqueID.create("019a9667-6a65-767a-a737-48234ee50a3a").data,
|
||||
companySlug: "acana",
|
||||
roles: ["admin"],
|
||||
};
|
||||
|
||||
@ -58,6 +58,7 @@ const format = (
|
||||
// Respetar fracciones si no vienen dadas en options.
|
||||
const nfOptions: Intl.NumberFormatOptions = {
|
||||
style: "currency",
|
||||
useGrouping: true,
|
||||
currency: normalizedDTO.currency_code,
|
||||
minimumFractionDigits: options?.minimumFractionDigits ?? scale,
|
||||
maximumFractionDigits: options?.maximumFractionDigits ?? scale,
|
||||
|
||||
@ -41,7 +41,7 @@ const toNumericString = (dto?: PercentageDTO | null, fallbackScale = 2): string
|
||||
const format = (
|
||||
dto: PercentageDTO,
|
||||
locale?: string,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
options?: { hideZeros?: boolean } & Intl.NumberFormatOptions,
|
||||
fallbackScale = 2
|
||||
): string => {
|
||||
if (isEmptyPercentageDTO(dto)) {
|
||||
@ -59,6 +59,9 @@ const format = (
|
||||
};
|
||||
|
||||
const absolute = toNumber(dto, fallbackScale); // ej. 12.5
|
||||
|
||||
if (absolute === 0 && options?.hideZeros) return "";
|
||||
|
||||
const fraction = absolute / 100; // ej. 0.125 para Intl percent
|
||||
|
||||
return new Intl.NumberFormat(locale, nfOptions).format(fraction);
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
import { ICustomParams, IDataSource } from "../datasource.interface";
|
||||
import type { AxiosInstance } from "axios";
|
||||
|
||||
import type { ICustomParams, IDataSource } from "../datasource.interface";
|
||||
|
||||
import { defaultAxiosRequestConfig } from "./create-axios-instance";
|
||||
|
||||
/**
|
||||
|
||||
@ -58,6 +58,7 @@
|
||||
"puppeteer": "^24.30.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"zod": "^4.1.11"
|
||||
|
||||
@ -55,7 +55,11 @@ export class IssuedInvoiceReportPresenter extends Presenter<
|
||||
locale,
|
||||
moneyOptions
|
||||
),
|
||||
discount_percentage: PercentageDTOHelper.format(issuedInvoiceDTO.discount_percentage, locale),
|
||||
discount_percentage: PercentageDTOHelper.format(
|
||||
issuedInvoiceDTO.discount_percentage,
|
||||
locale,
|
||||
{ hideZeros: true }
|
||||
),
|
||||
discount_amount: MoneyDTOHelper.format(
|
||||
issuedInvoiceDTO.discount_amount,
|
||||
locale,
|
||||
|
||||
@ -23,6 +23,9 @@ export class IssuedInvoiceReportHTMLPresenter extends TemplatePresenter {
|
||||
const invoiceDTO = dtoPresenter.toOutput(invoice);
|
||||
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
||||
|
||||
console.log(prettyDTO.verifactu);
|
||||
|
||||
|
||||
// Obtener y compilar la plantilla HTML
|
||||
const template = this.templateResolver.compileTemplate(
|
||||
"customer-invoices",
|
||||
|
||||
@ -23,6 +23,8 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
|
||||
format: "HTML",
|
||||
}) as IssuedInvoiceReportHTMLPresenter;
|
||||
|
||||
console.log(invoice);
|
||||
|
||||
const htmlData = htmlPresenter.toOutput(invoice, params);
|
||||
|
||||
// Generar el PDF con Puppeteer
|
||||
@ -33,8 +35,8 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
//page.setDefaultNavigationTimeout(60000);
|
||||
//page.setDefaultTimeout(60000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(60000);
|
||||
|
||||
await page.setContent(htmlData, {
|
||||
waitUntil: "networkidle2",
|
||||
|
||||
@ -33,8 +33,8 @@ export class ProformaReportPDFPresenter extends Presenter<
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
//page.setDefaultNavigationTimeout(60000);
|
||||
//page.setDefaultTimeout(60000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(60000);
|
||||
|
||||
await page.setContent(htmlData, {
|
||||
waitUntil: "networkidle2",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Tax, Taxes } from "@erp/core/api";
|
||||
import { type Tax, Taxes } from "@erp/core/api";
|
||||
|
||||
import { ItemAmount } from "../../value-objects";
|
||||
|
||||
export type ItemTaxTotal = {
|
||||
|
||||
@ -360,7 +360,7 @@ export class CustomerInvoiceRepository
|
||||
model: VerifactuRecordModel,
|
||||
as: "verifactu",
|
||||
required: false,
|
||||
attributes: ["id", "estado", "url", "uuid"],
|
||||
attributes: ["id", "estado", "url", "uuid", "qr"],
|
||||
},
|
||||
{
|
||||
model: CustomerModel,
|
||||
@ -578,7 +578,7 @@ export class CustomerInvoiceRepository
|
||||
model: VerifactuRecordModel,
|
||||
as: "verifactu",
|
||||
required: false,
|
||||
attributes: ["id", "estado", "url", "uuid"],
|
||||
attributes: ["id", "estado", "url", "uuid", "qr"],
|
||||
},
|
||||
{
|
||||
model: CustomerModel,
|
||||
|
||||
@ -57,7 +57,9 @@ export class CustomerInvoiceItemTaxModel extends Model<
|
||||
});
|
||||
}
|
||||
|
||||
static hooks(_database: Sequelize) {}
|
||||
static hooks(_database: Sequelize) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
export default (database: Sequelize) => {
|
||||
|
||||
@ -421,7 +421,7 @@ export default (database: Sequelize) => {
|
||||
|
||||
{
|
||||
name: "idx_invoice_company_series_number",
|
||||
fields: ["company_id", "series", "invoice_number"],
|
||||
fields: ["company_id", "series", "invoice_number", "is_proforma"],
|
||||
unique: true,
|
||||
}, // <- para consulta get
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ export class VerifactuRecordModel extends Model<
|
||||
declare estado: string;
|
||||
|
||||
declare url: CreationOptional<string>;
|
||||
declare qr: CreationOptional<Blob>;
|
||||
declare qr: CreationOptional<string>;
|
||||
declare uuid: CreationOptional<string>;
|
||||
declare operacion: CreationOptional<string>;
|
||||
|
||||
@ -77,7 +77,7 @@ export default (database: Sequelize) => {
|
||||
},
|
||||
|
||||
qr: {
|
||||
type: new DataTypes.BLOB(),
|
||||
type: new DataTypes.TEXT(),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
},
|
||||
|
||||
@ -50,6 +50,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"proformas": {
|
||||
"delete_proforma_dialog": {
|
||||
"title": "Delete proforma",
|
||||
"description": "Are you sure you want to delete proforma <strong>{{proformaRef}}</strong>? This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"success_title": "Proforma deleted",
|
||||
"error_title": "Error deleting proforma"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"proformas": {
|
||||
"title": "Proformas",
|
||||
|
||||
@ -49,6 +49,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"proformas": {
|
||||
"delete_proforma_dialog": {
|
||||
"title": "Eliminar proforma",
|
||||
"description": "¿Seguro que deseas eliminar la proforma <strong>{{proformaRef}}</strong>? Esta acción no se puede deshacer.",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando...",
|
||||
"success_title": "Proforma eliminada",
|
||||
"error_title": "Error al eliminar la proforma"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"proformas": {
|
||||
"title": "Proformas",
|
||||
|
||||
@ -11,7 +11,7 @@ const IssuedInvoicesLayout = lazy(() =>
|
||||
);
|
||||
|
||||
const ProformasListPage = lazy(() =>
|
||||
import("./proformas/pages").then((m) => ({ default: m.ProformaListPage }))
|
||||
import("./proformas/list").then((m) => ({ default: m.ProformaListPage }))
|
||||
);
|
||||
|
||||
const IssuedInvoiceListPage = lazy(() =>
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
calculateInvoiceHeaderAmounts,
|
||||
calculateInvoiceItemAmounts,
|
||||
} from "../../domain";
|
||||
import type { ProformaFormData } from "../../proformas/schema";
|
||||
import type { ProformaFormData } from "../../proformas/types";
|
||||
import type { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
|
||||
|
||||
export type UseProformaAutoRecalcParams = {
|
||||
|
||||
@ -3,7 +3,7 @@ import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { CreateProformaRequestSchema } from "../../common";
|
||||
import type { Proforma } from "../proformas/schema/proforma.api.schema";
|
||||
import type { Proforma } from "../proformas/types/proforma.api.schema";
|
||||
import type { InvoiceFormData } from "../schemas";
|
||||
|
||||
type CreateCustomerInvoicePayload = {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { KeyPrefix, Namespace, i18n } from "i18next";
|
||||
import { UseTranslationResponse, useTranslation as useI18NextTranslation } from "react-i18next";
|
||||
import type { KeyPrefix, Namespace, i18n } from "i18next";
|
||||
import {
|
||||
type UseTranslationResponse,
|
||||
useTranslation as useI18NextTranslation,
|
||||
} from "react-i18next";
|
||||
|
||||
import enResources from "../common/locales/en.json";
|
||||
import esResources from "../common/locales/es.json";
|
||||
|
||||
import { MODULE_NAME } from "./manifest";
|
||||
|
||||
const addMissingBundles = (i18n: i18n) => {
|
||||
|
||||
@ -12,8 +12,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DownloadIcon, MailIcon, MoreVerticalIcon } from "lucide-react";
|
||||
import { DownloadIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import QRCode from "react-qr-code";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { IssuedInvoiceSummaryData } from "../../../schema";
|
||||
@ -84,38 +85,39 @@ export function useIssuedInvoicesGridColumns(
|
||||
/>
|
||||
),
|
||||
accessorFn: (row) => row.verifactu.qr_code, // para ordenar/buscar por nombre
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-left">{row.original.verifactu.qr_code}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { verifactu } = row.original;
|
||||
const isPending = verifactu.status === "Pendiente";
|
||||
console.log(verifactu.status);
|
||||
return (
|
||||
<>
|
||||
{isPending ? (
|
||||
<QrCodeIcon className="size-8 text-muted-foreground" />
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<a href={verifactu.url} rel="noopener" target="_blank">
|
||||
<QrCodeIcon className="size-8" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="m-0 p-3">
|
||||
<QRCode className="bg-white p-8" value={verifactu.url} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
maxSize: 64,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
meta: {
|
||||
title: t("pages.issued_invoices.list.grid_columns.verifactu_qr_code"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "verifactu_url",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left"
|
||||
column={column}
|
||||
title={t("pages.issued_invoices.list.grid_columns.verifactu_url")}
|
||||
/>
|
||||
),
|
||||
accessorFn: (row) => row.verifactu.url, // para ordenar/buscar por nombre
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-left">{row.original.verifactu.url}</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
meta: {
|
||||
title: t("pages.issued_invoices.list.grid_columns.verifactu_url"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "recipient",
|
||||
header: ({ column }) => (
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
type TaxCatalogProvider,
|
||||
} from "@erp/core";
|
||||
|
||||
import type { Proforma, ProformaFormData, UpdateProformaInput } from "../schema";
|
||||
import type { Proforma, ProformaFormData, UpdateProformaInput } from "../types";
|
||||
|
||||
export type ProformaDtoAdapterContext = {
|
||||
taxCatalog: TaxCatalogProvider;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||
|
||||
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
|
||||
import type { ProformaSummaryPage } from "../types/proforma.api.schema";
|
||||
import type {
|
||||
ProformaSummaryData,
|
||||
ProformaSummaryPageData,
|
||||
} from "../schema/proforma-summary.web.schema";
|
||||
} from "../types/proforma-summary.web.schema";
|
||||
|
||||
/**
|
||||
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
export interface ChangeStatusResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function changeProformaStatusApi(
|
||||
dataSource: IDataSource,
|
||||
proformaId: string,
|
||||
newStatus: string
|
||||
): Promise<ChangeStatusResponse> {
|
||||
return dataSource.custom<ChangeStatusResponse>({
|
||||
path: `proformas/${proformaId}/status`,
|
||||
method: "patch",
|
||||
data: { new_status: newStatus },
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./change-proforma-status.api";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-change-status-dialog-controller";
|
||||
@ -0,0 +1,75 @@
|
||||
import { showErrorToast } from "@repo/rdx-ui/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
import type { ProformaSummaryData } from "../../types";
|
||||
import { useChangeProformaStatus } from "../hooks/use-change-proforma-status";
|
||||
|
||||
type ProformasType = {
|
||||
id: string;
|
||||
status: string;
|
||||
}[];
|
||||
|
||||
interface ChangeStatusDialogState {
|
||||
open: boolean;
|
||||
targetStatus: string | null;
|
||||
proformas: ProformaSummaryData[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useChangeStatusDialogController() {
|
||||
const { changeStatus } = useChangeProformaStatus();
|
||||
|
||||
const [state, setState] = React.useState<ChangeStatusDialogState>({
|
||||
open: false,
|
||||
targetStatus: null,
|
||||
proformas: [],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const openDialog = (proformas: ProformaSummaryData[]) => {
|
||||
setState({
|
||||
open: true,
|
||||
targetStatus: null,
|
||||
proformas,
|
||||
loading: false,
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
|
||||
const setTargetStatus = (status: string | null) => {
|
||||
setState((s) => ({ ...s, targetStatus: status }));
|
||||
};
|
||||
|
||||
const confirmChangeStatus = async () => {
|
||||
if (!state.targetStatus || state.proformas.length === 0) return;
|
||||
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
|
||||
for (const proforma of state.proformas) {
|
||||
await changeStatus(proforma.id.toString(), state.targetStatus, {
|
||||
onError: (err: unknown) => {
|
||||
const error = err as Error;
|
||||
showErrorToast("Error cambiando estado", error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return {
|
||||
open: state.open,
|
||||
proformas: state.proformas,
|
||||
targetStatus: state.targetStatus,
|
||||
isSubmitting: state.loading,
|
||||
|
||||
openDialog,
|
||||
closeDialog,
|
||||
setTargetStatus,
|
||||
confirmChangeStatus,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-change-proforma-status";
|
||||
@ -0,0 +1,51 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { PROFORMA_STATUS } from "../../types";
|
||||
import { changeProformaStatusApi } from "../api/change-proforma-status.api";
|
||||
|
||||
interface ChangeProformaStatusOptions {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: unknown) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
interface ChangeProformaStatusPayload {
|
||||
proformaId: string;
|
||||
newStatus: PROFORMA_STATUS;
|
||||
}
|
||||
|
||||
export function useChangeProformaStatus() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ proformaId, newStatus }: ChangeProformaStatusPayload) =>
|
||||
changeProformaStatusApi(dataSource, proformaId, newStatus),
|
||||
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function changeStatus(
|
||||
proformaId: string,
|
||||
newStatus: string,
|
||||
opts?: ChangeProformaStatusOptions
|
||||
) {
|
||||
try {
|
||||
opts?.onLoadingChange?.(true);
|
||||
await mutation.mutateAsync({ proformaId, newStatus: newStatus as PROFORMA_STATUS });
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
opts?.onError?.(err);
|
||||
} finally {
|
||||
opts?.onLoadingChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeStatus,
|
||||
isPending: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./controllers";
|
||||
export * from "./ui";
|
||||
@ -0,0 +1,300 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
FileCheckIcon,
|
||||
FileTextIcon,
|
||||
SendIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { PROFORMA_STATUS, PROFORMA_STATUS_TRANSITIONS } from "../../types";
|
||||
|
||||
interface ChangeStatusDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proformas: { id: string; status: string }[];
|
||||
targetStatus?: string;
|
||||
isSubmitting: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const STATUS_FLOW = [
|
||||
{
|
||||
status: PROFORMA_STATUS.DRAFT,
|
||||
icon: FileTextIcon,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
borderColor: "border-gray-300",
|
||||
},
|
||||
{
|
||||
status: PROFORMA_STATUS.SENT,
|
||||
icon: SendIcon,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
borderColor: "border-blue-300",
|
||||
},
|
||||
{
|
||||
status: PROFORMA_STATUS.APPROVED,
|
||||
icon: CheckCircle2Icon,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
borderColor: "border-green-300",
|
||||
},
|
||||
{
|
||||
status: PROFORMA_STATUS.REJECTED,
|
||||
icon: XCircleIcon,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
borderColor: "border-red-300",
|
||||
},
|
||||
{
|
||||
status: PROFORMA_STATUS.ISSUED,
|
||||
icon: FileCheckIcon,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-100",
|
||||
borderColor: "border-purple-300",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ChangeStatusDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
proformas,
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
}: ChangeStatusDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedStatus, setSelectedStatus] = useState<PROFORMA_STATUS | null>(null);
|
||||
|
||||
// Obtener estados disponibles comunes para todas las proformas seleccionadas
|
||||
const getAvailableStatuses = () => {
|
||||
if (!proformas || proformas.length === 0) return [];
|
||||
|
||||
// Intersección de estados disponibles para todas las proformas
|
||||
const firstProforma = proformas[0];
|
||||
let availableStatuses =
|
||||
PROFORMA_STATUS_TRANSITIONS[firstProforma.status as PROFORMA_STATUS] || [];
|
||||
|
||||
for (let i = 1; i < proformas.length; i++) {
|
||||
const currentStatuses =
|
||||
PROFORMA_STATUS_TRANSITIONS[proformas[i].status as PROFORMA_STATUS] || [];
|
||||
availableStatuses = availableStatuses.filter((status) => currentStatuses.includes(status));
|
||||
}
|
||||
|
||||
return availableStatuses;
|
||||
};
|
||||
|
||||
const availableStatuses = getAvailableStatuses();
|
||||
|
||||
const currentStatus = proformas.length === 1 ? proformas[0].status : null;
|
||||
|
||||
const getStatusType = (
|
||||
status: PROFORMA_STATUS
|
||||
): "past" | "current" | "available" | "unavailable" => {
|
||||
if (currentStatus === status) return "current";
|
||||
|
||||
// Si no hay un estado actual único, solo mostrar disponibles
|
||||
if (!currentStatus) {
|
||||
return availableStatuses.includes(status) ? "available" : "unavailable";
|
||||
}
|
||||
|
||||
// Determinar si es pasado basado en el flujo lógico
|
||||
const currentIndex = STATUS_FLOW.findIndex((s) => s.status === currentStatus);
|
||||
const statusIndex = STATUS_FLOW.findIndex((s) => s.status === status);
|
||||
|
||||
if (statusIndex < currentIndex) return "past";
|
||||
if (availableStatuses.includes(status)) return "available";
|
||||
return "unavailable";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cambiar estado de la proforma</DialogTitle>
|
||||
<DialogDescription>
|
||||
{proformas.length === 1
|
||||
? `Selecciona el nuevo estado para la proforma #${proformas[0].id}`
|
||||
: `Selecciona el nuevo estado para ${proformas.length} proformas seleccionadas`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{availableStatuses.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No hay transiciones de estado disponibles para las proformas seleccionadas.
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6">
|
||||
<div className="relative">
|
||||
{/* Línea de conexión */}
|
||||
<div className="absolute top-12 left-0 right-0 h-0.5 bg-gray-200" />
|
||||
|
||||
{/* Estados */}
|
||||
<div className="relative grid grid-cols-5 gap-2">
|
||||
{STATUS_FLOW.map((statusConfig, index) => {
|
||||
const statusType = getStatusType(statusConfig.status);
|
||||
const Icon = statusConfig.icon;
|
||||
const isSelected = selectedStatus === statusConfig.status;
|
||||
const isClickable = statusType === "available";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" key={statusConfig.status}>
|
||||
{/* Botón de estado */}
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 flex size-24 flex-col items-center justify-center rounded-xl border-2 transition-all",
|
||||
// Estado pasado
|
||||
statusType === "past" && ["border-gray-200 bg-gray-50", "opacity-60"],
|
||||
// Estado actual
|
||||
statusType === "current" && [
|
||||
statusConfig.borderColor,
|
||||
statusConfig.bgColor,
|
||||
"ring-2 ring-offset-2",
|
||||
`ring-${statusConfig.color.split("-")[1]}-200`,
|
||||
],
|
||||
// Estado disponible para seleccionar
|
||||
statusType === "available" && [
|
||||
isSelected
|
||||
? [
|
||||
statusConfig.borderColor,
|
||||
statusConfig.bgColor,
|
||||
"ring-2 ring-offset-2",
|
||||
`ring-${statusConfig.color.split("-")[1]}-300`,
|
||||
"scale-105",
|
||||
]
|
||||
: [
|
||||
"border-gray-300 bg-white hover:border-gray-400",
|
||||
"hover:scale-105 cursor-pointer",
|
||||
],
|
||||
],
|
||||
// Estado no disponible
|
||||
statusType === "unavailable" && [
|
||||
"border-gray-200 bg-gray-50",
|
||||
"opacity-40 cursor-not-allowed",
|
||||
]
|
||||
)}
|
||||
disabled={!isClickable}
|
||||
onClick={() => isClickable && setSelectedStatus(statusConfig.status)}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-8 mb-1",
|
||||
statusType === "past" && "text-gray-400",
|
||||
statusType === "current" && statusConfig.color,
|
||||
statusType === "available" &&
|
||||
(isSelected ? statusConfig.color : "text-gray-600"),
|
||||
statusType === "unavailable" && "text-gray-300"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indicador de estado actual */}
|
||||
{statusType === "current" && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2 -right-2 size-6 rounded-full border-2 border-white flex items-center justify-center",
|
||||
statusConfig.bgColor
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
statusConfig.color.replace("text-", "bg-")
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check de selección */}
|
||||
{isSelected && statusType !== "current" && (
|
||||
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-blue-600 border-2 border-white flex items-center justify-center">
|
||||
<CheckCircle2Icon className="size-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Etiqueta y descripción */}
|
||||
<div className="mt-3 text-center space-y-1 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
statusType === "past" && "text-gray-500",
|
||||
statusType === "current" && statusConfig.color,
|
||||
statusType === "available" &&
|
||||
(isSelected ? statusConfig.color : "text-gray-700"),
|
||||
statusType === "unavailable" && "text-gray-400"
|
||||
)}
|
||||
>
|
||||
{t(`catalog.proformas.status.${statusConfig.status}`)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{t(`catalog.proformas.status.${statusConfig.status}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Flecha de conexión (excepto el último) */}
|
||||
{index < STATUS_FLOW.length - 1 && (
|
||||
<ChevronRightIcon className="absolute top-10 -right-4 z-20 size-5 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-start gap-6 text-sm">
|
||||
{currentStatus && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-3 rounded-full bg-blue-600 ring-2 ring-blue-200" />
|
||||
<span className="text-muted-foreground">Estado actual</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-3 rounded-full border-2 border-gray-400 bg-white" />
|
||||
<span className="text-muted-foreground">Estados disponibles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-3 rounded-full bg-gray-300" />
|
||||
<span className="text-muted-foreground">Estados no disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Cambiando...
|
||||
</>
|
||||
) : (
|
||||
"Cambiar estado"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./change-status-dialog";
|
||||
@ -0,0 +1,8 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
export async function deleteProformaApi(
|
||||
dataSource: IDataSource,
|
||||
proformaId: string
|
||||
): Promise<void> {
|
||||
await dataSource.deleteOne("proformas", proformaId);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./delete-proforma.api";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-delete-proforma-dialog-controller";
|
||||
@ -0,0 +1,59 @@
|
||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||
import React from "react";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { ProformaSummaryData } from "../../types";
|
||||
import { useDeleteProforma } from "../hooks";
|
||||
|
||||
interface ProformaState {
|
||||
open: boolean;
|
||||
proforma: ProformaSummaryData | null;
|
||||
}
|
||||
|
||||
export function useDeleteProformaDialogController() {
|
||||
const { t } = useTranslation();
|
||||
const { mutate, isPending } = useDeleteProforma();
|
||||
|
||||
const [state, setState] = React.useState<ProformaState>({
|
||||
open: false,
|
||||
proforma: null,
|
||||
});
|
||||
|
||||
const openDialog = (proforma: ProformaSummaryData) => {
|
||||
setState({ open: true, proforma });
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!state.proforma) return;
|
||||
|
||||
mutate(
|
||||
{ proformaId: state.proforma.id },
|
||||
{
|
||||
onSuccess(data, variables, onMutateResult, context) {
|
||||
console.log("adios");
|
||||
console.log(data);
|
||||
showSuccessToast(t("proformas.delete_proforma_dialog.success_title"));
|
||||
closeDialog();
|
||||
},
|
||||
onError(error, variables, onMutateResult, context) {
|
||||
showErrorToast(t("proformas.delete_proforma_dialog.error_title"), error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
open: state.open,
|
||||
proforma: state.proforma,
|
||||
isSubmitting: isPending,
|
||||
|
||||
openDialog,
|
||||
closeDialog,
|
||||
|
||||
confirmDelete,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-delete-proforma";
|
||||
@ -0,0 +1,23 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { deleteProformaApi } from "../api";
|
||||
|
||||
interface useDeleteProformaPayload {
|
||||
proformaId: string;
|
||||
}
|
||||
|
||||
export function useDeleteProforma() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ proformaId }: useDeleteProformaPayload) =>
|
||||
deleteProformaApi(dataSource, proformaId),
|
||||
|
||||
onSuccess() {
|
||||
console.log("hola");
|
||||
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./controllers";
|
||||
@ -0,0 +1,63 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
interface DeleteProformaDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proformaRef?: string;
|
||||
isSubmitting: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteProformaDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
proformaRef,
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
}: DeleteProformaDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("proformas.delete_proforma_dialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={t("proformas.delete_proforma_dialog.description")}
|
||||
values={{ proformaRef }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t("proformas.delete_proforma_dialog.cancel")}
|
||||
</Button>
|
||||
|
||||
<Button disabled={isSubmitting} onClick={onConfirm} variant="destructive">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="mr-2 size-4" />
|
||||
{t("proformas.delete_proforma_dialog.deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>{t("proformas.delete_proforma_dialog.delete")}</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./delete-proforma-dialog";
|
||||
@ -1,4 +1,4 @@
|
||||
export * from "./use-proforma-items-columns";
|
||||
export * from "./use-issue-proforma-invoice";
|
||||
export * from "./use-proforma-query";
|
||||
export * from "./use-proforma-update-mutation";
|
||||
export * from "./use-proformas-query";
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
11111import
|
||||
{
|
||||
useDataSource;
|
||||
}
|
||||
from;
|
||||
("@erp/core/hooks");
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { InvoiceFormData } from "../schemas";
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
// hooks/use-issue-proforma-invoice.ts
|
||||
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const ISSUE_PROFORMA_INVOICE_KEY = ["proformas", "issue"] as const;
|
||||
|
||||
interface IssueProformaInvoicePayload {
|
||||
proformaId: string;
|
||||
}
|
||||
|
||||
export function useIssueProformaInvoice() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ISSUE_PROFORMA_INVOICE_KEY,
|
||||
mutationFn: ({ proformaId }: IssueProformaInvoicePayload) =>
|
||||
issueProformaInvoiceApi(dataSource, proformaId),
|
||||
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { Proforma } from "../schema/proforma.api.schema";
|
||||
import type { Proforma } from "../types/proforma.api.schema";
|
||||
|
||||
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { useDataSource } from "@erp/core/hooks";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
|
||||
import type { ProformaSummaryPage } from "../types/proforma.api.schema";
|
||||
|
||||
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||
"proforma",
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "./pages";
|
||||
export * from "./list";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issue-proforma-invoice.api";
|
||||
@ -0,0 +1,18 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
export interface IssueInvoiceResponse {
|
||||
invoiceId: string;
|
||||
proformaId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
export async function issueProformaInvoiceApi(
|
||||
dataSource: IDataSource,
|
||||
proformaId: string
|
||||
): Promise<IssueInvoiceResponse> {
|
||||
return dataSource.custom<IssueInvoiceResponse>({
|
||||
path: `proformas/${proformaId}/issue`,
|
||||
method: "put",
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-issue-proforma-dialog.controller";
|
||||
@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ProformaSummaryData } from "../../types";
|
||||
import { useIssueProformaMutation } from "../hooks/use-issue-proforma-mutation";
|
||||
|
||||
interface State {
|
||||
open: boolean;
|
||||
proforma: ProformaSummaryData | null;
|
||||
}
|
||||
|
||||
export function useProformaIssueDialogController() {
|
||||
const { mutate, isPending } = useIssueProformaMutation();
|
||||
|
||||
const [state, setState] = React.useState<State>({
|
||||
open: false,
|
||||
proforma: null,
|
||||
});
|
||||
|
||||
// abrir diálogo
|
||||
const openDialog = (p: ProformaSummaryData) => {
|
||||
setState({ open: true, proforma: p });
|
||||
};
|
||||
|
||||
// cerrar diálogo
|
||||
const closeDialog = () => {
|
||||
setState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
|
||||
// confirmar emisión
|
||||
const confirmIssue = () => {
|
||||
if (!state.proforma) return;
|
||||
|
||||
mutate(
|
||||
{ proformaId: state.proforma.id },
|
||||
{
|
||||
onSuccess() {
|
||||
closeDialog();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
open: state.open,
|
||||
proforma: state.proforma,
|
||||
isSubmitting: isPending,
|
||||
|
||||
openDialog,
|
||||
closeDialog,
|
||||
confirmIssue,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { issueProformaInvoiceApi } from "../api";
|
||||
|
||||
interface IssueProformaPayload {
|
||||
proformaId: string;
|
||||
}
|
||||
|
||||
interface IssueProformaResponse {
|
||||
proformaId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export function useIssueProformaMutation() {
|
||||
const dataSource = useDataSource();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ proformaId }: IssueProformaPayload) =>
|
||||
issueProformaInvoiceApi(dataSource, proformaId),
|
||||
|
||||
onSuccess(_data, _vars, _ctx) {
|
||||
// Refresca el listado de proformas
|
||||
queryClient.invalidateQueries({ queryKey: ["proformas"] });
|
||||
|
||||
// Opcional: refrescar facturas si existe la feature
|
||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./controllers";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issue-proforma-dialog";
|
||||
@ -0,0 +1,62 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
import { useProformaIssueDialogController } from "../controllers";
|
||||
|
||||
interface ProformaIssueDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proformaId: string;
|
||||
proformaReference: string;
|
||||
}
|
||||
|
||||
export function ProformaIssueDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
proformaId,
|
||||
proformaReference,
|
||||
}: ProformaIssueDialogProps) {
|
||||
const { issue, isSubmitting } = useProformaIssueDialogController();
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Seguro que quieres emitir la factura desde la proforma{" "}
|
||||
<strong>{proformaReference}</strong>? Esta acción es irreversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onClick={() => issue(proformaId, () => onOpenChange(false))}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Emitiendo...
|
||||
</>
|
||||
) : (
|
||||
"Emitir factura"
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-summary-dto.adapter";
|
||||
@ -0,0 +1,71 @@
|
||||
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
|
||||
|
||||
import type {
|
||||
ProformaSummaryData,
|
||||
ProformaSummaryPage,
|
||||
ProformaSummaryPageData,
|
||||
} from "../../types";
|
||||
|
||||
/**
|
||||
* Convierte el DTO completo de API a datos numéricos para el formulario.
|
||||
*/
|
||||
export const ProformaSummaryDtoAdapter = {
|
||||
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
|
||||
return {
|
||||
...pageDto,
|
||||
items: pageDto.items.map(
|
||||
(summaryDto) =>
|
||||
({
|
||||
...summaryDto,
|
||||
|
||||
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
|
||||
subtotal_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
|
||||
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
|
||||
summaryDto.discount_percentage
|
||||
),
|
||||
|
||||
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
|
||||
discount_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
|
||||
taxable_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
|
||||
taxes_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
|
||||
total_amount_fmt: formatCurrency(
|
||||
MoneyDTOHelper.toNumber(summaryDto.total_amount),
|
||||
Number(summaryDto.total_amount.scale || 2),
|
||||
summaryDto.currency_code,
|
||||
summaryDto.language_code
|
||||
),
|
||||
|
||||
//taxes: dto.taxes,
|
||||
}) as unknown as ProformaSummaryData
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { ProformaSummaryPage } from "../../types";
|
||||
|
||||
export async function getProformaListApi(
|
||||
dataSource: IDataSource,
|
||||
signal: AbortSignal,
|
||||
criteria: CriteriaDTO
|
||||
) {
|
||||
const response = dataSource.getList<ProformaSummaryPage>("proformas", {
|
||||
signal,
|
||||
...criteria,
|
||||
});
|
||||
|
||||
//return mapProformaList(raw);
|
||||
return response;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./get-proformas-list.api";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-proforma-list.controller.ts";
|
||||
export * from "./use-proforma-list-page.controller.ts";
|
||||
@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
|
||||
import { useChangeStatusDialogController } from "../../change-status";
|
||||
import { useDeleteProformaDialogController } from "../../delete";
|
||||
import { useProformaIssueDialogController } from "../../issue-proforma";
|
||||
import {
|
||||
type PROFORMA_STATUS,
|
||||
PROFORMA_STATUS_TRANSITIONS,
|
||||
type ProformaSummaryData,
|
||||
} from "../../types";
|
||||
|
||||
import { useProformaListController } from "./use-proforma-list.controller";
|
||||
|
||||
export function useProformaListPageController() {
|
||||
const listCtrl = useProformaListController();
|
||||
|
||||
// Controlador de diálogos
|
||||
const issueDialogCtrl = useProformaIssueDialogController();
|
||||
const changeStatusDialogCtrl = useChangeStatusDialogController();
|
||||
const deleteDialogCtrl = useDeleteProformaDialogController();
|
||||
|
||||
const handleIssueProforma = React.useCallback(
|
||||
(proforma: ProformaSummaryData) => {
|
||||
// Solo si approved → issued
|
||||
issueDialogCtrl.openDialog(proforma);
|
||||
},
|
||||
[issueDialogCtrl]
|
||||
);
|
||||
|
||||
const handleChangeStatusProforma = React.useCallback(
|
||||
(proforma: ProformaSummaryData, nextStatus: string) => {
|
||||
const proforma_status = proforma.status as PROFORMA_STATUS;
|
||||
const transitions = PROFORMA_STATUS_TRANSITIONS[proforma_status] ?? [];
|
||||
|
||||
if (!transitions.includes(nextStatus as PROFORMA_STATUS)) {
|
||||
console.warn(`Transición inválida: ${proforma.status} → ${nextStatus}`);
|
||||
return;
|
||||
}
|
||||
|
||||
changeStatusDialogCtrl.openDialog(proforma, nextStatus);
|
||||
},
|
||||
[changeStatusDialogCtrl]
|
||||
);
|
||||
|
||||
const handleDeleteProforma = React.useCallback(
|
||||
(proforma: ProformaSummaryData) => {
|
||||
console.log(proforma);
|
||||
deleteDialogCtrl.openDialog(proforma);
|
||||
},
|
||||
[deleteDialogCtrl]
|
||||
);
|
||||
|
||||
return {
|
||||
listCtrl,
|
||||
|
||||
issueDialogCtrl,
|
||||
changeStatusDialogCtrl,
|
||||
deleteDialogCtrl,
|
||||
|
||||
handleIssueProforma,
|
||||
handleChangeStatusProforma,
|
||||
handleDeleteProforma,
|
||||
};
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
// src/modules/proformas/hooks/use-proformas-list.ts
|
||||
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import { useDebounce } from "@repo/rdx-ui/components";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ProformaSummaryDtoAdapter } from "../../../adapters/proforma-summary-dto.adapter";
|
||||
import { useProformasQuery } from "../../../hooks";
|
||||
import { ProformaSummaryDtoAdapter } from "../adapters";
|
||||
import { useProformaListQuery } from "../hooks";
|
||||
|
||||
export const useProformasList = () => {
|
||||
export const useProformaListController = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState("");
|
||||
@ -29,7 +27,7 @@ export const useProformasList = () => {
|
||||
};
|
||||
}, [pageSize, pageIndex, debouncedQ, status]);
|
||||
|
||||
const query = useProformasQuery({ criteria });
|
||||
const query = useProformaListQuery({ criteria });
|
||||
const data = useMemo(
|
||||
() => (query.data ? ProformaSummaryDtoAdapter.fromDto(query.data) : undefined),
|
||||
[query.data]
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-proforma-list-query";
|
||||
@ -0,0 +1,38 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { ProformaSummaryPage } from "../../types";
|
||||
import { getProformaListApi } from "../api";
|
||||
|
||||
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
|
||||
"proforma",
|
||||
{
|
||||
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||
q: criteria?.q ?? "",
|
||||
filters: criteria?.filters ?? [],
|
||||
orderBy: criteria?.orderBy ?? "",
|
||||
order: criteria?.order ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
type ProformasQueryOptions = {
|
||||
enabled?: boolean;
|
||||
criteria?: CriteriaDTO;
|
||||
};
|
||||
|
||||
// Obtener todas las facturas
|
||||
export const useProformaListQuery = (options?: ProformasQueryOptions) => {
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? true;
|
||||
const criteria = options?.criteria ?? {};
|
||||
|
||||
return useQuery<ProformaSummaryPage, DefaultError>({
|
||||
queryKey: PROFORMAS_QUERY_KEY(criteria),
|
||||
queryFn: async ({ signal }) => getProformaListApi(dataSource, signal, criteria),
|
||||
enabled,
|
||||
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
||||
});
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./ui";
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "../../../issue-proforma/ui/issue-proforma-dialog";
|
||||
|
||||
export * from "./proformas-grid";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proformas-grid";
|
||||
@ -1,27 +1,28 @@
|
||||
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaSummaryPageData } from "../../../schema/proforma-summary.web.schema";
|
||||
import { useProformasGridColumns } from "../hooks";
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
import type { ProformaSummaryData, ProformaSummaryPageData } from "../../../../types";
|
||||
|
||||
interface ProformasGridProps {
|
||||
data: ProformaSummaryPageData;
|
||||
loading?: boolean;
|
||||
loading: boolean;
|
||||
|
||||
columns: ColumnDef<ProformaSummaryData, unknown>[];
|
||||
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
searchValue: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
onPageSizeChange: (s: number) => void;
|
||||
onRowClick?: (id: string) => void;
|
||||
onExportClick?: () => void;
|
||||
onStatusFilterChange?: (newStatus: string) => void;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
|
||||
onRowClick?: (proformaId: string) => void;
|
||||
}
|
||||
|
||||
export const ProformasGrid = ({
|
||||
data,
|
||||
loading,
|
||||
columns,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
@ -32,14 +33,6 @@ export const ProformasGrid = ({
|
||||
const { t } = useTranslation();
|
||||
const { items, total_items } = data;
|
||||
|
||||
const columns = useProformasGridColumns({
|
||||
onEdit: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
|
||||
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
|
||||
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
|
||||
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
|
||||
onDelete: (proforma) => null, //confirmDelete(inv.id),
|
||||
});
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<SkeletonDataTable
|
||||
@ -55,6 +48,7 @@ export const ProformasGrid = ({
|
||||
columns={columns}
|
||||
data={items}
|
||||
enablePagination
|
||||
enableRowSelection
|
||||
manualPagination
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
@ -17,25 +17,27 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaSummaryData } from "../../../schema";
|
||||
import { ProformaStatusBadge } from "../ui";
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
import {
|
||||
PROFORMA_STATUS_TRANSITIONS,
|
||||
type ProformaStatus,
|
||||
type ProformaSummaryData,
|
||||
} from "../../../../types";
|
||||
import { ProformaStatusBadge } from "../../components";
|
||||
|
||||
type GridActionHandlers = {
|
||||
onEdit?: (proforma: ProformaSummaryData) => void;
|
||||
onDuplicate?: (proforma: ProformaSummaryData) => void;
|
||||
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
|
||||
onSendEmail?: (proforma: ProformaSummaryData) => void;
|
||||
onDelete?: (proforma: ProformaSummaryData) => void;
|
||||
onEditClick?: (proforma: ProformaSummaryData) => void;
|
||||
onIssueClick?: (proforma: ProformaSummaryData) => void;
|
||||
onChangeStatusClick?: (proforma: ProformaSummaryData, nextStatus: string) => void;
|
||||
onDeleteClick?: (proforma: ProformaSummaryData) => void;
|
||||
};
|
||||
|
||||
export function useProformasGridColumns(
|
||||
actionHandlers: GridActionHandlers = {}
|
||||
): ColumnDef<ProformaSummaryData, unknown>[] {
|
||||
const { t } = useTranslation();
|
||||
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
|
||||
|
||||
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
|
||||
return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
@ -75,29 +77,34 @@ export function useProformasGridColumns(
|
||||
},
|
||||
cell: ({ row }) => <div className="font-medium">{row.getValue("invoice_number")}</div>,
|
||||
},
|
||||
|
||||
// Estado
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Estado",
|
||||
cell: ({ row }) => {
|
||||
const status = String(row.getValue("status"));
|
||||
const isIssued = status === "issued";
|
||||
const proforma = row.original;
|
||||
|
||||
const isIssued = proforma.status === "issued";
|
||||
const invoiceId = proforma.linked_invoice_id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ProformaStatusBadge status={status} />
|
||||
{isIssued && proforma.id && (
|
||||
<ProformaStatusBadge status={proforma.status} />
|
||||
|
||||
{/* Enlace discreto a factura real */}
|
||||
{isIssued && invoiceId && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="size-6" size="icon" variant="ghost">
|
||||
<a href={`/facturas/${proforma.id}`}>
|
||||
<a href={`/facturas/${invoiceId}`}>
|
||||
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
||||
<span className="sr-only">Ver factura #{proforma.id}</span>
|
||||
<span className="sr-only">Ver factura {invoiceId}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ver factura #{proforma.id}</TooltipContent>
|
||||
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
@ -105,6 +112,8 @@ export function useProformasGridColumns(
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// Cliente
|
||||
{
|
||||
accessorKey: "client_name",
|
||||
header: ({ column }) => {
|
||||
@ -124,7 +133,7 @@ export function useProformasGridColumns(
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-primary hover:underline"
|
||||
href={`/customers/${proforma.customer_id}`}
|
||||
>
|
||||
{proforma.recipient.name}
|
||||
@ -205,88 +214,105 @@ export function useProformasGridColumns(
|
||||
{
|
||||
id: "actions",
|
||||
header: "Acciones",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const proforma = row.original;
|
||||
const isIssued = proforma.status === "issued";
|
||||
const isApproved = proforma.status === "approved";
|
||||
const availableTransitions =
|
||||
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="size-8" size="icon" variant="ghost">
|
||||
<a href={`/proformas/${proforma.id}`}>
|
||||
<PencilIcon className="size-4" />
|
||||
<span className="sr-only">Editar</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Editar</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!isIssued && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="size-8"
|
||||
onClick={() => {
|
||||
row.toggleSelected(true);
|
||||
//setChangeStatusOpen(true);
|
||||
}}
|
||||
className="size-8 cursor-pointer"
|
||||
onClick={() => actionHandlers.onEditClick?.(proforma)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<span className="sr-only">Cambiar estado</span>
|
||||
<PencilIcon className="size-4" />
|
||||
<span className="sr-only">Editar</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Cambiar estado</TooltipContent>
|
||||
<TooltipContent>Editar</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isApproved && (
|
||||
{/* Cambiar estado */}
|
||||
{!isIssued &&
|
||||
availableTransitions.map((next_status) => (
|
||||
<TooltipProvider key={next_status}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="size-8"
|
||||
onClick={() => null /*handleIssueInvoice(proforma)*/}
|
||||
className="size-8 cursor-pointer"
|
||||
onClick={() =>
|
||||
actionHandlers.onChangeStatusClick?.(proforma, next_status)
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<FileTextIcon className="size-4" />
|
||||
<span className="sr-only">Emitir a factura</span>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<span className="sr-only">Cambiar estado</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Emitir a factura</TooltipContent>
|
||||
<TooltipContent>
|
||||
Cambiar a {t(`catalog.proformas.status.${next_status}`)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
))}
|
||||
|
||||
{/* Emitir factura: solo si approved */}
|
||||
{!isIssued && proforma.status === "approved" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="size-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
//setProformaToDelete(proforma.id);
|
||||
//setDeleteDialogOpen(true);
|
||||
className="size-8 cursor-pointer"
|
||||
onClick={() => actionHandlers.onIssueClick?.(proforma)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<FileTextIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Emitir a factura</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Eliminar */}
|
||||
{!isIssued && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="size-8 text-destructive hover:text-destructive cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
actionHandlers.onDeleteClick?.(proforma);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
<span className="sr-only">Eliminar</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Eliminar</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
|
||||
[t, actionHandlers]
|
||||
);
|
||||
}
|
||||
@ -1,2 +1 @@
|
||||
export * from "./proforma-status-badge";
|
||||
export * from "./proformas-grid";
|
||||
@ -0,0 +1,30 @@
|
||||
import { Badge } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import {
|
||||
type ProformaStatus,
|
||||
getProformaStatusButtonVariant,
|
||||
getProformaStatusColor,
|
||||
} from "../../../types";
|
||||
|
||||
export type ProformaStatusBadgeProps = {
|
||||
status: string | ProformaStatus; // permitir cualquier valor
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const normalizedStatus = status.toLowerCase() as ProformaStatus;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
|
||||
variant={getProformaStatusButtonVariant(normalizedStatus)}
|
||||
>
|
||||
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
ProformaStatusBadge.displayName = "ProformaStatusBadge";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@ -0,0 +1,148 @@
|
||||
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||
import { ErrorAlert } from "@erp/customers/components";
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { FilterIcon, PlusIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import { ChangeStatusDialog } from "../../../change-status";
|
||||
import { DeleteProformaDialog } from "../../../delete/ui";
|
||||
import { useProformaListPageController } from "../../controllers";
|
||||
import { ProformaIssueDialog } from "../blocks";
|
||||
import { ProformasGrid } from "../blocks/proformas-grid";
|
||||
import { useProformasGridColumns } from "../blocks/proformas-grid/use-proforma-grid-columns";
|
||||
|
||||
export const ProformaListPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
listCtrl,
|
||||
|
||||
issueDialogCtrl,
|
||||
changeStatusDialogCtrl,
|
||||
deleteDialogCtrl,
|
||||
|
||||
handleChangeStatusProforma,
|
||||
handleDeleteProforma,
|
||||
handleIssueProforma,
|
||||
} = useProformaListPageController();
|
||||
|
||||
const columns = useProformasGridColumns({
|
||||
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
|
||||
onIssueClick: handleIssueProforma,
|
||||
onDeleteClick: handleDeleteProforma,
|
||||
onChangeStatusClick: handleChangeStatusProforma,
|
||||
});
|
||||
|
||||
if (listCtrl.isError || !listCtrl.data) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
|
||||
title={t("pages.proformas.list.loadErrorTitle")}
|
||||
/>
|
||||
<BackHistoryButton />
|
||||
</AppContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader>
|
||||
<PageHeader
|
||||
description={t("pages.proformas.list.description")}
|
||||
rightSlot={
|
||||
<Button
|
||||
aria-label={t("pages.proformas.create.title")}
|
||||
onClick={() => navigate("/proformas/create")}
|
||||
>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("pages.proformas.create.title")}
|
||||
</Button>
|
||||
}
|
||||
title={t("pages.proformas.list.title")}
|
||||
/>
|
||||
</AppHeader>
|
||||
<AppContent>
|
||||
{/* Search and filters */}
|
||||
<div className="flex items-center justify-between gap-16">
|
||||
<SimpleSearchInput
|
||||
loading={listCtrl.isLoading}
|
||||
onSearchChange={listCtrl.setSearchValue}
|
||||
/>
|
||||
|
||||
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<FilterIcon aria-hidden className="mr-2 size-4" />
|
||||
<SelectValue placeholder={t("filters.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
|
||||
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
|
||||
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
|
||||
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
|
||||
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
|
||||
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ProformasGrid
|
||||
columns={columns}
|
||||
data={listCtrl.data}
|
||||
loading={listCtrl.isLoading}
|
||||
onChangeStatusClick={handleChangeStatusProforma}
|
||||
onDeleteClick={handleDeleteProforma}
|
||||
onIssueClick={handleIssueProforma}
|
||||
onPageChange={listCtrl.setPageIndex}
|
||||
onPageSizeChange={listCtrl.setPageSize}
|
||||
// acciones rápidas del grid → page controller
|
||||
//onRowClick={(id) => navigate(`/proformas/${id}`)}
|
||||
pageIndex={listCtrl.pageIndex}
|
||||
pageSize={listCtrl.pageSize}
|
||||
/>
|
||||
|
||||
{/* Emitir factura */}
|
||||
<ProformaIssueDialog
|
||||
isSubmitting={issueDialogCtrl.isSubmitting}
|
||||
onConfirm={issueDialogCtrl.confirmIssue}
|
||||
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
|
||||
open={issueDialogCtrl.open}
|
||||
proformaId={issueDialogCtrl.proforma?.id ?? 0}
|
||||
proformaReference={issueDialogCtrl.proforma?.reference ?? ""}
|
||||
/>
|
||||
|
||||
{/* Cambiar estado */}
|
||||
<ChangeStatusDialog
|
||||
isSubmitting={changeStatusDialogCtrl.isSubmitting}
|
||||
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) changeStatusDialogCtrl.closeDialog();
|
||||
}}
|
||||
open={changeStatusDialogCtrl.open}
|
||||
proformaRef={changeStatusDialogCtrl.proforma?.reference}
|
||||
proformas={changeStatusDialogCtrl.proformas}
|
||||
targetStatus={changeStatusDialogCtrl.targetStatus ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Eliminar */}
|
||||
<DeleteProformaDialog
|
||||
isSubmitting={deleteDialogCtrl.isSubmitting}
|
||||
onConfirm={deleteDialogCtrl.confirmDelete}
|
||||
onOpenChange={(o) => !o && deleteDialogCtrl.closeDialog()}
|
||||
open={deleteDialogCtrl.open}
|
||||
proformaRef={deleteDialogCtrl.proforma?.reference}
|
||||
/>
|
||||
</AppContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./use-proformas-grid-columns";
|
||||
export * from "./use-proformas-list";
|
||||
@ -1,513 +0,0 @@
|
||||
import { formatDate } from "@erp/core/client";
|
||||
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowBigRightDashIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ExternalLinkIcon,
|
||||
MailIcon,
|
||||
MoreVerticalIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaSummaryData } from "../../../schema";
|
||||
import { ProformaStatusBadge } from "../ui";
|
||||
|
||||
type GridActionHandlers = {
|
||||
onEdit?: (proforma: ProformaSummaryData) => void;
|
||||
onDuplicate?: (proforma: ProformaSummaryData) => void;
|
||||
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
|
||||
onSendEmail?: (proforma: ProformaSummaryData) => void;
|
||||
onDelete?: (proforma: ProformaSummaryData) => void;
|
||||
};
|
||||
|
||||
export function useProformasGridColumns(
|
||||
actionHandlers: GridActionHandlers = {}
|
||||
): ColumnDef<ProformaSummaryData, unknown>[] {
|
||||
const { t } = useTranslation();
|
||||
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
|
||||
|
||||
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
|
||||
() => [
|
||||
// Select
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
aria-label="Seleccionar todo"
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
aria-label="Seleccionar fila"
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
// Nº
|
||||
{
|
||||
accessorKey: "invoice_number",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left tabular-nums justify-end"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.invoice_number")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-semibold tabular-nums">{row.getValue("invoice_number")}</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.invoice_number"),
|
||||
},
|
||||
},
|
||||
// Estado
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.status")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<ProformaStatusBadge status={row.original.status} />
|
||||
{row.original.status === "issued" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="size-6" size="icon" variant="ghost">
|
||||
<a href={`/facturas/${row.original.issued_invoice_id}`}>
|
||||
<ExternalLinkIcon className="size-4 text-foreground" />
|
||||
<span className="sr-only">
|
||||
Ver factura #{row.original.issued_invoice_id}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ver factura #{row.original.issued_invoice_id}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.status"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "recipient",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.recipient")}
|
||||
/>
|
||||
),
|
||||
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
|
||||
enableHiding: false,
|
||||
minSize: 120,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.recipient;
|
||||
return (
|
||||
<div className="flex items-start gap-1">
|
||||
<div className="min-w-0 grid gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold truncate text-primary">{c.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{c.tin && <span className="text-xs text-muted-foreground truncate">{c.tin}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.recipient"),
|
||||
},
|
||||
},
|
||||
// Serie
|
||||
{
|
||||
accessorKey: "series",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.series")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
|
||||
enableSorting: false,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.series"),
|
||||
},
|
||||
},
|
||||
// Referencia
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.reference")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.reference"),
|
||||
},
|
||||
},
|
||||
|
||||
// Fecha factura
|
||||
{
|
||||
accessorKey: "invoice_date",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left tabular-nums"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.invoice_date")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-left tabular-nums">
|
||||
{formatDate(row.original.invoice_date)}
|
||||
</div>
|
||||
),
|
||||
size: 96,
|
||||
minSize: 96,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.invoice_date"),
|
||||
},
|
||||
},
|
||||
// Fecha operación
|
||||
{
|
||||
accessorKey: "operation_date",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left tabular-nums"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.operation_date")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-left tabular-nums">
|
||||
{formatDate(row.original.operation_date)}
|
||||
</div>
|
||||
),
|
||||
size: 96,
|
||||
minSize: 96,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.operation_date"),
|
||||
},
|
||||
},
|
||||
|
||||
// Subtotal amount
|
||||
{
|
||||
accessorKey: "subtotal_amount_fmt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-right tabular-nums"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.subtotal_amount")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-right tabular-nums">
|
||||
{row.original.subtotal_amount_fmt}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.subtotal_amount"),
|
||||
},
|
||||
},
|
||||
|
||||
// Discount amount
|
||||
{
|
||||
accessorKey: "discount_amount_fmt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-right tabular-nums"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.discount_amount")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-right tabular-nums">
|
||||
{row.original.discount_amount_fmt}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.discount_amount"),
|
||||
},
|
||||
},
|
||||
|
||||
// Taxes amount
|
||||
{
|
||||
accessorKey: "taxes_amount_fmt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-right tabular-nums"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.taxes_amount")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.taxes_amount"),
|
||||
},
|
||||
},
|
||||
|
||||
// Total amount
|
||||
{
|
||||
accessorKey: "total_amount_fmt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-right tabular-nums"
|
||||
column={column}
|
||||
title={t("pages.proformas.list.grid_columns.total_amount")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-semibold text-right tabular-nums">
|
||||
{row.original.total_amount_fmt}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
meta: {
|
||||
title: t("pages.proformas.list.grid_columns.total_amount"),
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────
|
||||
// Acciones
|
||||
// ─────────────────────────────
|
||||
{
|
||||
id: "actions",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-left"
|
||||
column={column}
|
||||
title={t("common.actions")}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 110,
|
||||
minSize: 96,
|
||||
cell: ({ row }) => {
|
||||
const proforma = row.original;
|
||||
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
{/* Emitir factura: approved -> issued */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("common.edit_row")}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(proforma);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowBigRightDashIcon aria-hidden="true" className="size-4 " />
|
||||
<span className="sr-only">Emitir</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.edit_row")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Editar (acción primaria) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("common.edit_row")}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(proforma);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<EditIcon aria-hidden="true" className="size-4 " />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.edit_row")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Duplicar */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("common.duplicate_row")}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate?.(proforma);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CopyIcon aria-hidden="true" className="size-4 " />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Descargar en PDF */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("common.download_pdf")}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadPdf?.(proforma);
|
||||
}}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("common.delete_row")}
|
||||
className="cursor-pointer text-destructive hover:bg-destructive/90 hover:text-white"
|
||||
onClick={() => onDelete?.(proforma)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" className="size-4" />
|
||||
<span className="sr-only">{t("common.delete_row")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.delete_row")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Menú demás acciones */}
|
||||
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
|
||||
{false !== false && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("common.more_actions")}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary"
|
||||
onClick={stop}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" className="size-4" />
|
||||
<span className="sr-only">{t("common.more_actions")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDuplicate?.(proforma)}
|
||||
>
|
||||
<CopyIcon className="mr-2 size-4" />
|
||||
{t("common.duplicate_row")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDownloadPdf?.(proforma)}
|
||||
>
|
||||
<DownloadIcon className="mr-2 size-4" />
|
||||
{t("common.download_pdf")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSendEmail?.(proforma)}
|
||||
>
|
||||
<MailIcon className="mr-2 size-4" />
|
||||
{t("common.send_email")}
|
||||
</DropdownMenuItem>{" "}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
|
||||
onClick={() => onDelete?.(proforma)}
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
|
||||
{t("common.delete_row")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
title: t("common.actions"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
|
||||
);
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||
import { ErrorAlert } from "@erp/customers/components";
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { FilterIcon, PlusIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
import { useProformasList } from "./hooks";
|
||||
import { ProformasGrid } from "./ui";
|
||||
|
||||
export const ProformaListPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const list = useProformasList();
|
||||
|
||||
if (list.isError || !list.data) {
|
||||
return (
|
||||
<AppContent>
|
||||
<ErrorAlert
|
||||
message={(list.error as Error)?.message || "Error al cargar el listado"}
|
||||
title={t("pages.proformas.list.loadErrorTitle")}
|
||||
/>
|
||||
<BackHistoryButton />
|
||||
</AppContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader>
|
||||
<PageHeader
|
||||
description={t("pages.proformas.list.description")}
|
||||
rightSlot={
|
||||
<Button
|
||||
aria-label={t("pages.proformas.create.title")}
|
||||
onClick={() => navigate("/proformas/create")}
|
||||
>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("pages.proformas.create.title")}
|
||||
</Button>
|
||||
}
|
||||
title={t("pages.proformas.list.title")}
|
||||
/>
|
||||
</AppHeader>
|
||||
<AppContent>
|
||||
{/* Search and filters */}
|
||||
<div className="flex items-center justify-between gap-16">
|
||||
<SimpleSearchInput loading={list.isLoading} onSearchChange={list.setSearchValue} />
|
||||
|
||||
<Select defaultValue="all" onValueChange={list.setStatusFilter}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<FilterIcon aria-hidden className="mr-2 size-4" />
|
||||
<SelectValue placeholder={t("filters.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
|
||||
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
|
||||
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
|
||||
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
|
||||
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
|
||||
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ProformasGrid
|
||||
data={list.data}
|
||||
loading={list.isLoading}
|
||||
onPageChange={list.setPageIndex}
|
||||
onPageSizeChange={list.setPageSize}
|
||||
onSearchChange={list.setSearchValue}
|
||||
onStatusFilterChange={list.setStatusFilter}
|
||||
pageIndex={list.pageIndex}
|
||||
pageSize={list.pageSize}
|
||||
searchValue={list.search}
|
||||
/>
|
||||
</AppContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,63 +0,0 @@
|
||||
import { Badge } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
|
||||
export type ProformaStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
|
||||
|
||||
export type ProformaStatusBadgeProps = {
|
||||
status: string | ProformaStatus; // permitir cualquier valor
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const normalizedStatus = status.toLowerCase() as ProformaStatus;
|
||||
|
||||
const getVariant = (
|
||||
status: ProformaStatus
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "outline";
|
||||
case "sent":
|
||||
return "secondary";
|
||||
case "approved":
|
||||
return "default";
|
||||
case "rejected":
|
||||
return "destructive";
|
||||
case "issued":
|
||||
return "default";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = (status: ProformaStatus): string => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
||||
case "sent":
|
||||
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
|
||||
case "approved":
|
||||
return "bg-green-100 text-green-700 hover:bg-green-100";
|
||||
case "rejected":
|
||||
return "bg-red-100 text-red-700 hover:bg-red-100";
|
||||
case "issued":
|
||||
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(getColor(normalizedStatus), "font-semibold", className)}
|
||||
variant={getVariant(normalizedStatus)}
|
||||
>
|
||||
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
ProformaStatusBadge.displayName = "ProformaStatusBadge";
|
||||
@ -14,7 +14,7 @@ import {
|
||||
type ProformaFormData,
|
||||
ProformaFormSchema,
|
||||
defaultProformaFormData,
|
||||
} from "../../schema";
|
||||
} from "../../types";
|
||||
|
||||
import { useProformaContext } from "./context";
|
||||
import { ProformaUpdateForm } from "./proforma-update-form";
|
||||
|
||||
@ -2,7 +2,7 @@ import { FormDebug } from "@erp/core/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { type FieldErrors, useFormContext } from "react-hook-form";
|
||||
|
||||
import type { ProformaFormData } from "../../schema";
|
||||
import type { ProformaFormData } from "../../types";
|
||||
|
||||
import { ProformaBasicInfoFields, ProformaItems, ProformaRecipient, ProformaTotals } from "./ui";
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
import type { ProformaFormData } from "../../../../schema";
|
||||
import type { ProformaFormData } from "../../../../types";
|
||||
|
||||
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -12,7 +12,7 @@ import type { ComponentProps } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
import type { ProformaFormData } from "../../../../schema";
|
||||
import type { ProformaFormData } from "../../../../types";
|
||||
import { useProformaContext } from "../../context";
|
||||
|
||||
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import type { ProformaFormData, ProformaItemFormData } from "../../../../../schema";
|
||||
import type { ProformaFormData, ProformaItemFormData } from "../../../../../types";
|
||||
|
||||
export function ItemRowEditor({
|
||||
row,
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
/** biome-ignore-all lint/complexity/noForEach: <explanation> */
|
||||
/** biome-ignore-all lint/suspicious/useIterableCallbackReturn: <explanation> */
|
||||
|
||||
import { useProformaItemsColumns } from "@erp/customer-invoices/web/proformas/hooks";
|
||||
import { useProformaGridColumns } from "@erp/customer-invoices/web/proformas/hooks";
|
||||
import { DataTable, useWithRowSelection } from "@repo/rdx-ui/components";
|
||||
import { useMemo } from "react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useProformaAutoRecalc } from "../../../../../../hooks";
|
||||
import { useTranslation } from "../../../../../../i18n";
|
||||
import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../schema";
|
||||
import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../types";
|
||||
import { useProformaContext } from "../../../context";
|
||||
|
||||
import { ItemRowEditor } from "./item-row-editor";
|
||||
@ -28,7 +28,7 @@ export const ItemsEditor = () => {
|
||||
name: "items",
|
||||
});
|
||||
|
||||
const baseColumns = useWithRowSelection(useProformaItemsColumns(), true);
|
||||
const baseColumns = useWithRowSelection(useProformaGridColumns(), true);
|
||||
const columns = useMemo(() => baseColumns, [baseColumns]);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./proforma.api.schema";
|
||||
export * from "./proforma.form.schema";
|
||||
export * from "./proforma-status";
|
||||
export * from "./proforma-summary.web.schema";
|
||||
@ -0,0 +1,54 @@
|
||||
export enum PROFORMA_STATUS {
|
||||
DRAFT = "draft",
|
||||
SENT = "sent",
|
||||
APPROVED = "approved",
|
||||
REJECTED = "rejected",
|
||||
ISSUED = "issued",
|
||||
}
|
||||
|
||||
// Transiciones válidas según reglas del dominio
|
||||
export const PROFORMA_STATUS_TRANSITIONS: Record<PROFORMA_STATUS, PROFORMA_STATUS[]> = {
|
||||
[PROFORMA_STATUS.DRAFT]: [PROFORMA_STATUS.SENT],
|
||||
[PROFORMA_STATUS.SENT]: [PROFORMA_STATUS.APPROVED, PROFORMA_STATUS.REJECTED],
|
||||
[PROFORMA_STATUS.APPROVED]: [PROFORMA_STATUS.ISSUED, PROFORMA_STATUS.DRAFT],
|
||||
[PROFORMA_STATUS.REJECTED]: [PROFORMA_STATUS.DRAFT],
|
||||
[PROFORMA_STATUS.ISSUED]: [],
|
||||
};
|
||||
|
||||
export type ProformaStatus = `${PROFORMA_STATUS}`;
|
||||
|
||||
export const getProformaStatusButtonVariant = (
|
||||
status: ProformaStatus
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "outline";
|
||||
case "sent":
|
||||
return "secondary";
|
||||
case "approved":
|
||||
return "default";
|
||||
case "rejected":
|
||||
return "destructive";
|
||||
case "issued":
|
||||
return "default";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export const getProformaStatusColor = (status: ProformaStatus): string => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
||||
case "sent":
|
||||
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
|
||||
case "approved":
|
||||
return "bg-green-100 text-green-700 hover:bg-green-100";
|
||||
case "rejected":
|
||||
return "bg-red-100 text-red-700 hover:bg-red-100";
|
||||
case "issued":
|
||||
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "../../issue-proforma/ui/issue-proforma-dialog";
|
||||
|
||||
export * from "./proforma-delete-dialog";
|
||||
export * from "./proforma-issue-dialog";
|
||||
export * from "./proforma-layout";
|
||||
export * from "./proforma-tax-summary";
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useState } from "react";
|
||||
|
||||
interface IssueInvoiceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proformaId: number;
|
||||
proformaReference: string;
|
||||
}
|
||||
|
||||
export function ProformaIssueDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
proformaId,
|
||||
proformaReference,
|
||||
}: IssueInvoiceDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
//const { toast } = useToast();
|
||||
|
||||
const handleIssue = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
/*try {
|
||||
const result = await issueInvoiceFromProforma(proformaId);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Factura emitida",
|
||||
description: `Se ha emitido la factura #${result.invoiceId} desde la proforma.`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error instanceof Error ? error.message : "Error al emitir la factura",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}*/
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Estás seguro de que deseas emitir una factura de cliente desde la proforma{" "}
|
||||
<strong>{proformaReference}</strong>?
|
||||
<br />
|
||||
<br />
|
||||
Esta acción creará una nueva factura definitiva y la proforma pasará al estado
|
||||
"Emitida", no pudiendo modificarse ni eliminarse posteriormente.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button disabled={isSubmitting} onClick={handleIssue}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Emitiendo...
|
||||
</>
|
||||
) : (
|
||||
"Emitir factura"
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { useProformaContext } from "../../pages/update/context";
|
||||
import type { ProformaFormData } from "../../schema";
|
||||
import type { ProformaFormData } from "../../types";
|
||||
|
||||
export const ProformaTaxSummary = (props: ComponentProps<"fieldset">) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>{{ asset 'tailwind.css' }}</style>
|
||||
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
|
||||
|
||||
<title>Factura</title>
|
||||
<style>
|
||||
<style type="text/css">
|
||||
/* ---------------------------- */
|
||||
/* ESTRUCTURA CABECERA */
|
||||
/* ---------------------------- */
|
||||
@ -43,10 +44,12 @@
|
||||
|
||||
/* Bloque derecho */
|
||||
.right-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
width: 40%;
|
||||
display: flex;
|
||||
flex-direction: column; /* uno encima de otro */
|
||||
align-items: flex-end; /* o flex-start / center según quieras */
|
||||
justify-content: flex-start;
|
||||
width: 40%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.factura-img {
|
||||
@ -202,6 +205,7 @@
|
||||
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
|
||||
<div class="top-header">
|
||||
<div class="left-block">
|
||||
|
||||
<img src="{{asset 'logo_acana.jpg'}}" alt="Logo Acana" class="logo" />
|
||||
|
||||
<div class="company-text">
|
||||
@ -217,7 +221,19 @@
|
||||
</div>
|
||||
|
||||
<div class="right-block">
|
||||
<div>
|
||||
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
|
||||
</div>
|
||||
{{#if verifactu.qr_code}}
|
||||
<div style="display: flex; align-items: center; gap: 8px; padding-top: 10px;">
|
||||
<div style="text-align: right;">
|
||||
QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
|
||||
</div>
|
||||
<div>
|
||||
<img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 100px; height: 100px;" />
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -227,7 +243,6 @@
|
||||
<div class="info-box">
|
||||
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
|
||||
<p>Fecha: <strong>{{invoice_date}}</strong></p>
|
||||
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
|
||||
</div>
|
||||
|
||||
<div class="info-box info-dire">
|
||||
@ -244,7 +259,6 @@
|
||||
<main id="main">
|
||||
<section id="details">
|
||||
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>{{ asset 'tailwind.css' }}</style>
|
||||
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
|
||||
|
||||
<title>Factura proforma</title>
|
||||
<style type="text/css">
|
||||
/* ---------------------------- */
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"duplicate": "Duplicate",
|
||||
"remove": "Remove",
|
||||
"move_up": "Move up",
|
||||
"move_down": "Move down"
|
||||
"move_down": "Move down",
|
||||
"clear_selection": "Clear selection"
|
||||
},
|
||||
"pagination": {
|
||||
"goto_first_page": "Go to first page",
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
"duplicate": "Duplicar",
|
||||
"remove": "Eliminar",
|
||||
"move_up": "Subir",
|
||||
"move_down": "Bajar"
|
||||
"move_down": "Bajar",
|
||||
"clear_selection": "Quitar selección"
|
||||
},
|
||||
"pagination": {
|
||||
"goto_first_page": "Ir a la primera página",
|
||||
|
||||
@ -1,33 +1,29 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
||||
import { Separator } from "@repo/shadcn-ui/components/separator"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { Separator } from "@repo/shadcn-ui/components/separator";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
data-slot="item-group"
|
||||
role="list"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
className={cn("my-0", className)}
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
@ -49,7 +45,7 @@ const itemVariants = cva(
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
@ -57,18 +53,17 @@ function Item({
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
data-size={size}
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
@ -78,15 +73,14 @@ const itemMediaVariants = cva(
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
image: "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
@ -95,88 +89,72 @@ function ItemMedia({
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium", className)}
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
data-slot="item-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<div className={cn("flex items-center gap-2", className)} data-slot="item-actions" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -190,4 +168,4 @@ export {
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user