This commit is contained in:
David Arranz 2024-08-23 18:47:51 +02:00
parent 9931a4a839
commit 26bc608131
46 changed files with 334 additions and 1293 deletions

View File

@ -66,10 +66,28 @@ export const QuotesDataTable = ({
const columns = useMemo<ColumnDef<IListQuotes_Response_DTO, any>[]>(
() => [
{
id: "reference" as const,
accessorKey: "reference",
header: () => <>{t("quotes.list.columns.reference")}</>,
cell: ({ renderValue }) => <div className='text-left text-ellipsis'>{renderValue()}</div>,
enableResizing: false,
},
{
id: "status" as const,
accessorKey: "status",
header: () => <>{t("quotes.list.columns.status")}</>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cell: ({ row: { original } }) => <ColorBadge label={original.status} />,
enableResizing: false,
},
{
id: "date" as const,
accessor: "date",
header: () => <>{t("quotes.list.columns.date")}</>,
header: () => (
<div className='text-right text-ellipsis'>{t("quotes.list.columns.date")}</div>
),
cell: ({ row: { original } }) => {
const quoteDate = UTCDateValue.create(original.date);
return (
@ -80,6 +98,13 @@ export const QuotesDataTable = ({
},
enableResizing: false,
},
{
id: "customer_reference" as const,
accessorKey: "customer_reference",
header: () => <>{t("quotes.list.columns.customer_reference")}</>,
cell: ({ renderValue }) => <div className='text-left text-ellipsis'>{renderValue()}</div>,
enableResizing: false,
},
{
id: "customer_information" as const,
accessorKey: "customer_information",
@ -104,21 +129,7 @@ export const QuotesDataTable = ({
enableResizing: false,
size: 640,
},
{
id: "reference" as const,
accessorKey: "reference",
header: () => <>{t("quotes.list.columns.reference")}</>,
cell: ({ renderValue }) => <div className='text-left text-ellipsis'>{renderValue()}</div>,
enableResizing: false,
},
{
id: "status" as const,
accessorKey: "status",
header: () => <>{t("quotes.list.columns.status")}</>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cell: ({ row: { original } }) => <ColorBadge label={original.status} />,
enableResizing: false,
},
{
id: "total_price" as const,
accessor: "total_price",

View File

@ -27,11 +27,11 @@ export const QuoteGeneralCardEditor = () => {
errors={formState.errors}
/>
<FormTextField
label={t("quotes.form_fields.reference.label")}
description={t("quotes.form_fields.reference.desc")}
label={t("quotes.form_fields.customer_reference.label")}
description={t("quotes.form_fields.customer_reference.desc")}
disabled={formState.disabled}
placeholder={t("quotes.form_fields.reference.placeholder")}
{...register("reference", {
placeholder={t("quotes.form_fields.customer_reference.placeholder")}
{...register("customer_reference", {
required: false,
})}
/>

View File

@ -29,8 +29,9 @@ export const QuoteCreate = () => {
const defaultValues = useMemo(
() => ({
date: new Date(Date.now()).toUTCString(),
customer_reference: "",
customer_information: "",
reference: "",
//reference: "",
}),
[]
);
@ -40,9 +41,10 @@ export const QuoteCreate = () => {
defaultValues,
resolver: joiResolver(
Joi.object({
reference: Joi.string().required(),
date: Joi.date().required(),
//reference: Joi.string().required(),
customer_information: Joi.string().required(),
date: Joi.date().required(),
customer_reference: Joi.string(),
}),
{
//messages: SpanishJoiMessages,
@ -99,13 +101,22 @@ export const QuoteCreate = () => {
</div>
<div className='grid w-6/12 gap-6 mx-auto'>
<FormTextField
{/*<FormTextField
className='row-span-2'
name='reference'
required
label={t("quotes.form_fields.reference.label")}
description={t("quotes.form_fields.reference.desc")}
placeholder={t("quotes.form_fields.reference.placeholder")}
/>*/}
<FormTextField
className='row-span-2'
name='customer_reference'
required
label={t("quotes.form_fields.customer_reference.label")}
description={t("quotes.form_fields.customer_reference.desc")}
placeholder={t("quotes.form_fields.customer_reference.placeholder")}
/>
<FormDatePickerField

View File

@ -42,6 +42,7 @@ export const QuoteEdit = () => {
() => ({
date: "",
reference: "",
customer_reference: "",
customer_information: "",
lang_code: "",
currency_code: "",
@ -244,7 +245,7 @@ export const QuoteEdit = () => {
<div className='flex items-center gap-4'>
<BackHistoryButton />
<h1 className='flex-1 text-xl font-semibold tracking-tight shrink-0 whitespace-nowrap sm:grow-0'>
{t("quotes.edit.title")}
{t("quotes.edit.title")} {data.reference}
</h1>
<ColorBadge label={data.status} className='ml-auto sm:ml-0' />

View File

@ -279,9 +279,9 @@ export const useQuotes = () => {
},
});
const download = (id: string, filename?: string) => {
const download = (id: string, filename: string) => {
const url = actions.getQuotePDFDownloadURL(id);
return downloader.download(url, filename ?? "ssaas");
return downloader.download(url, filename);
};
return {

View File

@ -112,6 +112,7 @@
"date": "Date",
"reference": "Reference",
"status": "Status",
"customer_reference": "Customer Ref.",
"customer_information": "Customer",
"total_price": "Imp. total",
"actions": {
@ -197,6 +198,11 @@
"desc": "Quote currency",
"placeholder": ""
},
"customer_reference": {
"label": "Customer reference",
"desc": "Customer reference for this quote",
"placeholder": ""
},
"customer_information": {
"label": "Customer's contact data",
"desc": "Recommendation: enter the customer's name on the first line, the address on the second line, and the zip code and city/state on the third line.",
@ -205,7 +211,7 @@
"payment_method": {
"label": "Payment method",
"placeholder": "",
"desc": "Method of payment of the quote"
"desc": "Method of payment for this quote"
},
"notes": {
"label": "Notes",

View File

@ -194,6 +194,11 @@
"desc": "Moneda de la cotización",
"placeholder": ""
},
"customer_reference": {
"label": "Referencia del cliente",
"desc": "Referencia para el cliente de esta cotización",
"placeholder": ""
},
"customer_information": {
"label": "Datos del cliente",
"desc": "Recomensación: escriba el nombre del cliente en la primera línea, la direccion en la segunda y el código postal y ciudad en la tercera.",

View File

@ -3,6 +3,7 @@ import { IUseCase, IUseCaseError, UseCaseError } from "@/contexts/common/applica
import { IRepositoryManager } from "@/contexts/common/domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { SequelizeBusinessTransactionType } from "@/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction";
import {
Collection,
CurrencyData,
@ -25,11 +26,13 @@ import {
IQuoteRepository,
Quote,
QuoteCustomer,
QuoteCustomerReference,
QuoteItem,
QuoteReference,
QuoteStatus,
} from "../../domain";
import { ISalesContext } from "../../infrastructure";
import { generateQuoteReferenceForDealer } from "../services";
export type CreateQuoteResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
@ -40,12 +43,13 @@ export class CreateQuoteUseCase
{
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
private _dealer?: Dealer;
private _dealer: Dealer;
private _transaction: SequelizeBusinessTransactionType;
constructor(context: ISalesContext) {
this._adapter = context.adapter;
this._repositoryManager = context.repositoryManager;
this._dealer = context.dealer;
this._dealer = context.dealer!;
}
async execute(request: ICreateQuote_Request_DTO) {
@ -57,8 +61,6 @@ export class CreateQuoteUseCase
return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, message));
}
const dealerId = this._dealer.id;
const idOrError = ensureIdIsValid(id);
if (idOrError.isFailure) {
const message = idOrError.error.message; //`Quote ID ${quoteDTO.id} is not valid`;
@ -67,58 +69,58 @@ export class CreateQuoteUseCase
);
}
// Comprobar que no existe un quote previo con esos datos
this._transaction = this._adapter.startTransaction();
const quoteRepository = this._getQuoteRepository();
const idExists = await quoteRepository().exists(idOrError.object);
if (idExists) {
const message = `Another quote with same ID exists`;
return Result.fail(
UseCaseError.create(UseCaseError.RESOURCE_ALREADY_EXITS, message, {
path: "id",
})
);
}
// Crear quote
const quoteOrError = this._tryCreateQuoteInstance(request, idOrError.object, dealerId);
if (quoteOrError.isFailure) {
const { error: domainError } = quoteOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
case DomainError.INVALID_INPUT_DATA:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message = "El presupuesto tiene algún dato erróneo.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = domainError.message;
break;
}
return Result.fail(UseCaseError.create(errorCode, message, domainError));
}
return this._saveQuote(quoteOrError.object);
}
private async _saveQuote(quote: Quote) {
// Guardar el contacto
const transaction = this._adapter.startTransaction();
const quoteRepository = this._getQuoteRepository();
let quoteRepo: IQuoteRepository;
try {
await transaction.complete(async (t) => {
quoteRepo = quoteRepository({ transaction: t });
await quoteRepo.create(quote);
});
return await this._transaction.complete(async (t) => {
const quoteRepo = quoteRepository({ transaction: t });
return Result.ok<Quote>(quote);
// Comprobar que no existe un quote previo con esos datos
const idExists = await quoteRepo.exists(idOrError.object);
if (idExists) {
const message = `Another quote with same ID exists`;
return Result.fail(
UseCaseError.create(UseCaseError.RESOURCE_ALREADY_EXITS, message, {
path: "id",
})
);
}
// Generate Reference
const quoteReference = await generateQuoteReferenceForDealer(this._dealer, quoteRepo);
// Crear quote
const quoteOrError = this._tryCreateQuoteInstance(
request,
idOrError.object,
quoteReference
);
if (quoteOrError.isFailure) {
const { error: domainError } = quoteOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
case DomainError.INVALID_INPUT_DATA:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message = "El presupuesto tiene algún dato erróneo.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = domainError.message;
break;
}
return Result.fail(UseCaseError.create(errorCode, message, domainError));
}
await quoteRepo.create(quoteOrError.object);
return Result.ok<Quote>(quoteOrError.object);
});
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, _error.message));
@ -128,8 +130,10 @@ export class CreateQuoteUseCase
private _tryCreateQuoteInstance(
quoteDTO: ICreateQuote_Request_DTO,
quoteId: UniqueID,
dealerId: UniqueID
quoteReference: QuoteReference
): Result<Quote, IDomainError> {
const dealerId: UniqueID = this._dealer.id;
const statusOrError = QuoteStatus.create(quoteDTO.status);
if (statusOrError.isFailure) {
return Result.fail(statusOrError.error);
@ -140,11 +144,6 @@ export class CreateQuoteUseCase
return Result.fail(dateOrError.error);
}
const referenceOrError = QuoteReference.create(quoteDTO.reference);
if (referenceOrError.isFailure) {
return Result.fail(referenceOrError.error);
}
const languageOrError = Language.createFromCode(
quoteDTO.lang_code ?? this._dealer?.language.code
);
@ -152,6 +151,11 @@ export class CreateQuoteUseCase
return Result.fail(languageOrError.error);
}
const customerReferenceOrError = QuoteCustomerReference.create(quoteDTO.customer_reference);
if (customerReferenceOrError.isFailure) {
return Result.fail(customerReferenceOrError.error);
}
const customerOrError = QuoteCustomer.create(quoteDTO.customer_information);
if (customerOrError.isFailure) {
return Result.fail(customerOrError.error);
@ -259,8 +263,9 @@ export class CreateQuoteUseCase
{
status: statusOrError.object,
date: dateOrError.object,
reference: referenceOrError.object,
reference: quoteReference,
language: languageOrError.object,
customerReference: customerReferenceOrError.object,
customer: customerOrError.object,
currency: currencyOrError.object,
paymentMethod: paymentOrError.object,

View File

@ -31,6 +31,7 @@ import {
IQuoteRepository,
Quote,
QuoteCustomer,
QuoteCustomerReference,
QuoteItem,
QuoteReference,
QuoteStatus,
@ -153,6 +154,11 @@ export class UpdateQuoteUseCase
return Result.fail(languageOrError.error);
}
const customerReferenceOrError = QuoteCustomerReference.create(quoteDTO.customer_reference);
if (customerReferenceOrError.isFailure) {
return Result.fail(customerReferenceOrError.error);
}
const customerOrError = QuoteCustomer.create(quoteDTO.customer_information);
if (customerOrError.isFailure) {
return Result.fail(customerOrError.error);
@ -253,6 +259,7 @@ export class UpdateQuoteUseCase
date: dateOrError.object,
reference: referenceOrError.object,
language: languageOrError.object,
customerReference: customerReferenceOrError.object,
customer: customerOrError.object,
currency: currencyOrError.object,
paymentMethod: paymentOrError.object,

View File

@ -0,0 +1,23 @@
import { IDealer, IQuoteRepository, QuoteReference } from "../../domain";
const generateQuoteReferenceForDealer = async (
dealer: IDealer,
quoteRepository: IQuoteRepository
): Promise<QuoteReference> => {
// Obtener la última referencia del dealer
const lastQuote = await quoteRepository.findLastQuoteByDealerId(dealer.id);
// Generar la nueva referencia usando el Value Object
const quoteReferenceResult = QuoteReference.fromPrevious(
lastQuote ? lastQuote.reference : null,
dealer
);
if (quoteReferenceResult.isFailure) {
throw new Error("Error al crear la referencia de presupuesto");
}
return quoteReferenceResult.object;
};
export { generateQuoteReferenceForDealer };

View File

@ -0,0 +1 @@
export * from "./QuoteService";

View File

@ -29,6 +29,8 @@ export interface IDealer {
additionalInfo: KeyValueMap;
status: DealerStatus;
currency: CurrencyData;
getAcronym: () => string;
}
export class Dealer extends AggregateRoot<IDealerProps> implements IDealer {
@ -37,6 +39,12 @@ export class Dealer extends AggregateRoot<IDealerProps> implements IDealer {
return Result.ok<Dealer>(user);
}
public static generateDealerNameAcronyn(dealerName: Name | string): string {
return typeof dealerName === "string"
? Name.generateAcronym(dealerName)
: dealerName.getAcronym();
}
get user_id(): UniqueID {
return this.props.user_id;
}
@ -60,4 +68,8 @@ export class Dealer extends AggregateRoot<IDealerProps> implements IDealer {
get currency(): CurrencyData {
return this.props.currency;
}
public getAcronym(): string {
return this.props.name.getAcronym();
}
}

View File

@ -20,6 +20,7 @@ export interface IQuoteProps {
status: QuoteStatus;
date: UTCDateValue;
reference: QuoteReference;
customerReference: QuoteReference;
customer: QuoteCustomer;
language: Language;
currency: CurrencyData;
@ -43,6 +44,7 @@ export interface IQuote {
status: QuoteStatus;
date: UTCDateValue;
reference: QuoteReference;
customerReference: QuoteReference;
customer: QuoteCustomer;
language: Language;
currency: CurrencyData;
@ -115,6 +117,10 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
return this.props.reference;
}
get customerReference() {
return this.props.customerReference;
}
get customer() {
return this.props.customer;
}

View File

@ -21,7 +21,7 @@ export class QuoteCustomer extends StringValueObject {
.default("")
.trim()
.max(QuoteCustomer.MAX_LENGTH)
.label(options.label ? options.label : "value");
.label(options.label ? options.label : "customer_information");
return RuleValidator.validate<string>(rule, value);
}

View File

@ -0,0 +1,45 @@
import {
DomainError,
IStringValueObjectOptions,
Result,
RuleValidator,
StringValueObject,
handleDomainError,
} from "@shared/contexts";
import { UndefinedOr } from "@shared/utilities";
import Joi from "joi";
export interface IQuoteCustomerReferenceOptions extends IStringValueObjectOptions {}
export class QuoteCustomerReference extends StringValueObject {
private static readonly MAX_LENGTH = 255;
protected static validate(value: UndefinedOr<string>, options: IQuoteCustomerReferenceOptions) {
const rule = Joi.string()
.allow(null)
.allow("")
.default("")
.trim()
.max(QuoteCustomerReference.MAX_LENGTH)
.label(options.label ? options.label : "value");
return RuleValidator.validate<string>(rule, value);
}
public static create(value: UndefinedOr<string>, options: IQuoteCustomerReferenceOptions = {}) {
const _options = {
label: "customer_reference",
...options,
};
const validationResult = QuoteCustomerReference.validate(value, _options);
if (validationResult.isFailure) {
return Result.fail(
handleDomainError(DomainError.INVALID_INPUT_DATA, validationResult.error.message, _options)
);
}
return Result.ok(new QuoteCustomerReference(validationResult.object));
}
}

View File

@ -6,8 +6,9 @@ import {
StringValueObject,
handleDomainError,
} from "@shared/contexts";
import { UndefinedOr } from "@shared/utilities";
import { NullOr, UndefinedOr } from "@shared/utilities";
import Joi from "joi";
import { IDealer } from "../Dealer";
export interface IQuoteReferenceOptions extends IStringValueObjectOptions {}
@ -42,4 +43,24 @@ export class QuoteReference extends StringValueObject {
return Result.ok(new QuoteReference(validationResult.object));
}
public static fromPrevious(
previousReference: NullOr<QuoteReference>,
dealer: IDealer
): Result<QuoteReference> {
const lastNumber = previousReference
? QuoteReference._extractSequenceFromReference(previousReference)
: 0;
const newNumber = lastNumber + 1;
const dealerAcronym = dealer.getAcronym();
const newReference = `${dealerAcronym}-${newNumber.toString().padStart(4, "0")}`;
return QuoteReference.create(newReference);
}
private static _extractSequenceFromReference(reference: QuoteReference): number {
const parts = reference.toString().split("-");
return parseInt(parts[parts.length - 1], 10);
}
}

View File

@ -1,5 +1,6 @@
export * from "./Quote";
export * from "./QuoteCustomer";
export * from "./QuoteCustomerReference";
export * from "./QuoteItem";
export * from "./QuoteReference";
export * from "./QuoteStatus";

View File

@ -1,2 +1,3 @@
export * from "./entities";
export * from "./repository";
export * from "./services";

View File

@ -12,4 +12,6 @@ export interface IQuoteRepository extends IRepository<Quote> {
findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Quote>>;
removeById(id: UniqueID): Promise<void>;
findLastQuoteByDealerId(dealerId: UniqueID): Promise<Quote | null>;
}

View File

@ -0,0 +1,3 @@
import { IApplicationService } from "@/contexts/common/application";
export interface IQuoteReferenceGeneratorService extends IApplicationService {}

View File

@ -0,0 +1 @@
export * from "./QuoteReferenceGeneratorService.interface";

View File

@ -112,6 +112,28 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
public async removeById(id: UniqueID, force: boolean = false): Promise<void> {
return this._removeById("Quote_Model", id);
}
public async findLastQuoteByDealerId(dealerId: UniqueID): Promise<Quote | null> {
const Quote_Model: ModelDefined<any, any> = this._adapter.getModel("Quote_Model");
const QuoteItem_Model: ModelDefined<any, any> = this._adapter.getModel("QuoteItem_Model");
const rawQuote: any = await Quote_Model.findOne({
include: [
{
model: QuoteItem_Model,
as: "items",
},
],
where: { dealer_id: dealerId.toString() },
order: [["created_at", "DESC"]], // Ordenamos por referencia en orden descendente
});
if (!rawQuote === true) {
return null;
}
return this.mapper.mapToDomain(rawQuote);
}
}
export const registerQuoteRepository = (context: ISalesContext) => {

View File

@ -1,7 +1,9 @@
import { IContext } from "@/contexts/common/infrastructure";
import { Dealer } from "../domain";
import { Dealer, IQuoteReferenceGeneratorService } from "../domain";
export interface ISalesContext extends IContext {
//services: IApplicationService;
services: {
QuoteReferenceGeneratorService: IQuoteReferenceGeneratorService;
};
dealer?: Dealer;
}

View File

@ -35,16 +35,16 @@ export class CreateQuoteController extends ExpressController {
async executeImpl() {
try {
const quoteDTO: ICreateQuote_Request_DTO = this.req.body;
/*const user = <User | undefined>this.req.user;
const dealer = this.context.dealer;
if (!user) {
const errorMessage = "Unexpected missing user data";
if (!dealer) {
const errorMessage = "Unexpected missing dealer data";
const infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage
);
return this.internalServerError(errorMessage, infraError);
}*/
}
// Validaciones de DTO
const quoteDTOOrError = ensureCreateQuote_Request_DTOIsValid(quoteDTO);

View File

@ -18,6 +18,7 @@ export const CreateQuotePresenter: ICreateQuotePresenter = {
status: quote.status.toString(),
date: quote.date.toISO8601(),
reference: quote.reference.toString(),
customer_reference: quote.customerReference.toString(),
customer_information: quote.customer.toString(),
lang_code: quote.language.toString(),
currency_code: quote.currency.toString(),

View File

@ -18,6 +18,7 @@ export const GetQuotePresenter: IGetQuotePresenter = {
status: quote.status.toString(),
date: quote.date.toISO8601(),
reference: quote.reference.toString(),
customer_reference: quote.customerReference.toString(),
customer_information: quote.customer.toString(),
lang_code: quote.language.toString(),
currency_code: quote.currency.toString(),

View File

@ -23,6 +23,7 @@ export const ListQuotesPresenter: IListQuotesPresenter = {
date: quote.date.toISO8601(),
reference: quote.reference.toString(),
customer_information: quote.customer.toString(),
customer_reference: quote.customerReference.toString(),
lang_code: quote.language.toString(),
currency_code: quote.currency.toString(),

View File

@ -64,6 +64,7 @@ const map = (quote: Quote, context: ISalesContext) => {
status: quote.status.toString(),
date: quote.date.toLocaleDateString(),
reference: quote.reference.toString(),
customer_reference: quote.customerReference.toString(),
customer_information: quote.customer.toString(),
lang_code: quote.language.toString(),
currency_code: quote.currency.toString(),

View File

@ -1,466 +0,0 @@
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presupuesto #29b6ac1a-ce83-44be-ac3a-714ceee1983f</title>
<style type="text/css">
.header {
background-color: #f0f0f0;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.content {
padding: 20px;
}
/* Estilos para la impresión */
@media print {
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: #f0f0f0;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.content {
margin-top: 100px;
/* Ajusta según el tamaño de tu cabecera */
}
@page {
margin-top: 0;
/* Ajusta según el tamaño de tu cabecera */
}
}
</style>
</head>
<body style="background-color: white;">
<header>
<div class="page-header">
<div class="header-content">
<div class="dealer-info">
<h1>DISTRIBUIDOR OFICIAL</h1>
<div class="contact-info">
<div class="mt-2">
<img src="https://via.placeholder.com/100x50" alt="Logo distribuidor" />
</div>
<div class="mt-2 text-sm">
GRUPO DE INTERIORES GTH, S.L.
Calle Castilla, 204
28009 Madrid
</div>
</div>
</div>
<div class="text-right">
<img src="https://via.placeholder.com/150x50" alt="Uecko Logo" />
<p class="text-xs text-gray-500">Essential Furniture</p>
<p class="text-xs text-gray-500">PREMIO AMBARRO DEL AÑO 2021</p>
<p class="text-xs text-gray-500">LUXURY SPAIN</p>
<p class="text-xs text-gray-500">ELLE 2021</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
<div>
<p class="text-sm"><strong>Presupuesto nº:</strong> 29b6ac1a-ce83-44be-ac3a-714ceee1983f</p>
<p class="text-sm"><strong>Fecha:</strong> 2024-07-24T00:00:00.000Z</p>
<p class="text-sm"><strong>Validez:</strong> 2024-02-05</p>
<p class="text-sm"><strong>Vendedor:</strong> GRUPO DE INTERIORES GTH, S.L.</p>
<p class="text-sm"><strong>Referencia cliente:</strong> PR/8633</p>
</div>
<div>
<p class="text-sm">ALICIA PELAEZ SEVILLA, S.L.
AVDA. NAZARET - SEPARACION DE AMBIENTES Y PUERTAS DE PASO V2 SD</p>
</div>
</div>
<div class="mb-4">
<h2 style="font-size: 1.25rem; font-weight: 600;">Cotización</h2>
</div>
</div>
</header>
<footer>
<div class="page-footer">
<div class="page-footer-content">
<p>Información básica sobre protección de datos</p>
<p></p>
</div>
</div>
</footer>
<main>
<table>
<thead>
<tr>
<th class="page-header-space">HEADER
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="page-content">
<div class="page">
<!-- Contenido de la página -->
</div>
<div class="page">
<table class="table-header">
<thead>
<tr>
<th class="px-2 py-2 text-right border">Cant.</th>
<th class="px-2 py-2 border">Descripción</th>
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
<th class="px-2 py-2 text-right border">Subtotal</th>
<th class="px-2 py-2 text-right border">Dto (%)</th>
<th class="px-2 py-2 text-right border">Importe total</th>
</tr>
</thead>
<tbody class="table-body">
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium"> CORREDERAS</td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P1
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x1500 mm. incluyendo Kit de casonetto. </td>
<td class="content-start px-2 py-2 text-right">14,50 </td>
<td class="content-start px-2 py-2 text-right">14,50 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">14,50 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P7
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x820 mm incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P8
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x820 mm incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P9
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x960 mm incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">9,95 </td>
<td class="content-start px-2 py-2 text-right">9,95 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">9,95 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P10
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x965 mm. incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">9,95 </td>
<td class="content-start px-2 py-2 text-right">9,95 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">9,95 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P12
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x760 mm. incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P13
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x760 mm. incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P14
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x747 mm. incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">8,86 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P15
Puerta de paso corredera lisa lacada BLANCO AV-501 a determinar de dimensiones 2600x900 mm. incluyendo Kit de casonetto.</td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">9,60 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium"> ABATIBLES</td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P2
Puertas de paso abatible 1/c enrasada, sin cabecero, lisa lacado BLANCO AV-501 de 50 mm de espesor, de dimensiones de hoja 2600x860 mm. con cerco de 100/140x35, tapetas hasta 120x19 mm., resbalón unificado y bisagras ocultas negras. Apertura der</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P3
Puertas de paso abatible 1/c enrasada, sin cabecero, lisa lacado BLANCO AV-501 de 50 mm de espesor, de dimensiones de hoja 2600x885 mm. con cerco de 100/140x35, tapetas hasta 120x19 mm., resbalón unificado y bisagras ocultas negras. Apertura der</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1,75</td>
<td class="px-2 py-2 font-medium">Forrado paramentos 19 mm lacado m²</td>
<td class="content-start px-2 py-2 text-right">1,56 </td>
<td class="content-start px-2 py-2 text-right">2,72 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">2,72 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P4
Puertas de paso abatible 1/c enrasada, sin cabecero, lisa lacado BLANCO AV-501 de 50 mm de espesor, de dimensiones de hoja 2600x985 mm. con cerco de 100/140x35, tapetas hasta 120x19 mm., resbalón unificado y bisagras ocultas negras. Apertura der</td>
<td class="content-start px-2 py-2 text-right">11,81 </td>
<td class="content-start px-2 py-2 text-right">11,81 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">11,81 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P5
Puertas de paso abatible 1/c enrasada, sin cabecero, lisa lacado BLANCO AV-501 de 50 mm de espesor, de dimensiones de hoja 2600x885 mm. con cerco de 100/140x35, tapetas hasta 120x19 mm., resbalón unificado y bisagras ocultas negras. Apertura izq</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">1</td>
<td class="px-2 py-2 font-medium"> P6
Puertas de paso abatible 1/c enrasada, sin cabecero, lisa lacado BLANCO AV-501 de 50 mm de espesor, de dimensiones de hoja 2600x885 mm. con cerco de 100/140x35, tapetas hasta 120x19 mm., resbalón unificado y bisagras ocultas negras. Apertura der</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">11,05 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium"> NOTA.-
-Casonetto propiedad del cliente.
-Manivelas y/o condenas no incluidas.</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium"> DESCUENTO </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right"></td>
<td class="px-2 py-2 font-medium">null</td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
<td class="content-start px-2 py-2 text-right"></td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">161</td>
<td class="px-2 py-2 font-medium"> M.l. Rodapie lacado BLANCO AV-501 a determinar DE 200X19 mm con dos estrías horizontales </td>
<td class="content-start px-2 py-2 text-right">0,15 </td>
<td class="content-start px-2 py-2 text-right">24,79 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">24,79 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">0</td>
<td class="px-2 py-2 font-medium"> DESCUENTO </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">0</td>
<td class="px-2 py-2 font-medium">null</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">3</td>
<td class="px-2 py-2 font-medium"> Entrega de material en domicilio cliente</td>
<td class="content-start px-2 py-2 text-right">1,20 </td>
<td class="content-start px-2 py-2 text-right">3,60 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">3,60 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">0</td>
<td class="px-2 py-2 font-medium"> DESCUENTO </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">0</td>
<td class="px-2 py-2 font-medium">null</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">9</td>
<td class="px-2 py-2 font-medium"> Instalación PUERTA DE PASO CORREDERA</td>
<td class="content-start px-2 py-2 text-right">1,30 </td>
<td class="content-start px-2 py-2 text-right">11,70 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">11,70 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">5</td>
<td class="px-2 py-2 font-medium"> Instalación PUERTA DE PASO ABATIBLE</td>
<td class="content-start px-2 py-2 text-right">1,00 </td>
<td class="content-start px-2 py-2 text-right">5,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">5,00 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">159</td>
<td class="px-2 py-2 font-medium"> Instalación M.L. RODAPIE </td>
<td class="content-start px-2 py-2 text-right">0,09 </td>
<td class="content-start px-2 py-2 text-right">13,52 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">13,52 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">0</td>
<td class="px-2 py-2 font-medium"> DESCUENTO </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
</tr>
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">0</td>
<td class="px-2 py-2 font-medium">null</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
<td class="content-start px-2 py-2 text-right">0</td>
<td class="content-start px-2 py-2 text-right">0,00 </td>
</tr>
</tbody>
</table>
</div>
<div class="page"></div>
<div class="flex items-center justify-between pb-4 mb-4 border-b">
<div>
<div class="pt-4 border-t">
<p class="text-sm"><strong>Forma de pago:</strong> 60% Aceptación presupuesto
30% Un día antes de la entrega del material
10% Restante a la finalización de la obra</p>
</div>
<div class="pt-4 border-t">
<p class="text-sm"><strong>Notas:</strong> Este presupuesto no se considerará en firme hasta confirmar medidas y diseños definitivos. </p>
</div>
</div>
<div>
<table class="table-footer">
<tbody>
<tr class="border-b">
<td class="px-4 py-2 border">Importe neto</td>
<td class="px-4 py-2 border"></td>
<td class="px-4 py-2 text-right border">207,12 </td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">% Descuento</td>
<td class="px-4 py-2 text-right border"></td>
<td class="px-4 py-2 text-right border"></td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">Impuestos</td>
<td class="px-4 py-2 text-right border"></td>
<td class="px-4 py-2 text-right border"></td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">Envío</td>
<td class="px-4 py-2 text-right border"></td>
<td class="px-4 py-2 text-right border"></td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 font-medium border">Total</td>
<td class="px-4 py-2 font-medium border"></td>
<td class="px-4 py-2 font-medium text-right border">207,12 </td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@ -1,319 +0,0 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<title>Presupuesto #{{id}}</title>
<style type="text/css">
@page {
font-family: Pacifico;
margin: 1cm;
@bottom-left {
color: #1ee494;
content: "♥ Thank you!";
}
@bottom-right {
color: #a9a;
content: "contact@courtbouillon.org | courtbouillon.org";
font-size: 9pt;
}
}
.fullpage {
page: full;
}
.page {
margin: 7cm 0 0 0;
page-break-after: avoid;
}
html {
color: #14213d;
font-family: Source Sans Pro;
font-size: 11pt;
line-height: 1.6;
}
body {
margin: 0;
}
header {
position: fixed;
top: 0mm;
width: 100%;
border-bottom: 1px solid black;
/* for demo */
background: transparent;
/* for demo */
}
.page-header-space {}
.page-footer-space {}
h1 {
color: #1ee494;
font-family: Pacifico;
font-size: 40pt;
margin: 0;
}
aside {
display: flex;
margin: 2em 0 4em;
}
aside address {
flex: 1;
font-style: normal;
white-space: pre-line;
}
aside address#from {
border: 1px solid red;
color: #a9a;
flex: 1;
}
aside address#to {
border: 1px solid green;
text-align: right;
}
dl {
position: absolute;
right: 0;
text-align: right;
top: 0;
}
dt,
dd {
display: inline;
margin: 0;
}
dt {
color: #a9a;
}
dt::before {
content: "";
display: block;
}
dt::after {
content: "";
}
table {
border-collapse: collapse;
width: 100%;
}
th {
border-bottom: 0.2mm solid #a9a;
color: #a9a;
font-size: 10pt;
font-weight: 400;
padding-bottom: 0.25cm;
text-transform: uppercase;
}
td {
padding-top: 7mm;
}
td:last-of-type {
color: #1ee494;
font-weight: bold;
text-align: right;
}
th,
td {
text-align: center;
}
th:first-of-type,
td:first-of-type {
text-align: left;
}
th:last-of-type,
td:last-of-type {
text-align: right;
}
footer {
content: "";
display: block;
height: 6cm;
}
table#total {
background: #f6f6f6;
border-color: #f6f6f6;
border-style: solid;
border-width: 2cm 3cm;
bottom: 0;
font-size: 20pt;
margin: 0 -3cm;
position: absolute;
width: 18cm;
}
@media print {
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<main>
<table>
<thead>
<tr>
<th class="page-header-space">
<header>
<h1>Invoice</h1>
<aside>
<address id="from">
{{dealer.contact_information}}
</address>
<address id="to">
{{customer_information}}
</address>
</aside>
<dl id="informations">
</header>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="page">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tincidunt metus eu consectetur rutrum.
Praesent tempor facilisis dapibus. Aliquam cursus diam ac vehicula pulvinar. Integer lacinia non odio et
condimentum. Aenean faucibus cursus mi, sed interdum turpis sagittis a. Quisque quis pellentesque mi. Ut
erat eros, posuere sed scelerisque ut, pharetra vitae tellus. Suspendisse ligula sapien, laoreet ac
hendrerit sit amet, viverra vel mi. Pellentesque faucibus nisl et dolor pharetra, vel mattis massa
venenatis. Integer congue condimentum nisi, sed tincidunt velit tincidunt non. Nulla sagittis sed lorem
pretium aliquam. Praesent consectetur volutpat nibh, quis pulvinar est volutpat id. Cras maximus odio
posuere suscipit venenatis. Donec rhoncus scelerisque metus, in tempus erat rhoncus sed. Morbi massa
sapien,
porttitor id urna vel, volutpat blandit velit. Cras sit amet sem eros. Quisque commodo facilisis
tristique.
Proin pellentesque sodales rutrum. Vestibulum purus neque, congue vel dapibus in, venenatis ut felis.
Donec
et ligula enim. Sed sapien sapien, tincidunt vitae lectus quis, ultricies rhoncus mi. Nunc dapibus nulla
tempus nunc interdum, sed facilisis ex pellentesque. Nunc vel lorem leo. Cras pharetra sodales metus.
Cras
lacus ex, consequat at consequat vel, laoreet ac dui. Curabitur aliquam, sapien quis congue feugiat,
nisi
nisl feugiat diam, sed vehicula velit nulla ac nisl. Aliquam quis nisi euismod massa blandit pharetra
nec
eget nunc. Etiam eros ante, auctor sit amet quam vel, fringilla faucibus leo. Morbi a pulvinar nulla.
Praesent sed vulputate nisl. Orci varius natoque penatibus et magnis dis parturient montes, nascetur
ridiculus mus. Aenean commodo mollis iaculis. Maecenas consectetur enim vitae mollis venenatis. Ut
scelerisque pretium orci id laoreet. In sit amet pharetra diam. Vestibulum in molestie lorem. Nunc
gravida,
eros non consequat fermentum, ex orci vestibulum orci, non accumsan sem velit ac lectus. Vivamus
malesuada
lacus nec velit dignissim, ac fermentum nulla pretium. Aenean mi nisi, convallis sed tempor in,
porttitor
eu
libero. Praesent et molestie ante. Duis suscipit vitae purus sit amet aliquam. Vestibulum lectus justo,
lobortis a purus a, dapibus efficitur metus. Suspendisse potenti. Duis dictum ex lorem. Suspendisse nec
ligula consectetur magna hendrerit ullamcorper et eget mauris. Etiam vestibulum sodales diam, eget
venenatis
nunc luctus quis. Ut fermentum placerat neque nec elementum. Praesent orci erat, rhoncus vitae est eu,
dictum molestie metus. Cras et fermentum elit. Aenean eget augue lacinia, varius ante in, ullamcorper
dolor.
Cras viverra purus non egestas consectetur. Nulla nec dolor ac lectus convallis aliquet sed a metus.
Suspendisse eu imperdiet nunc, id pulvinar risus. Maecenas varius sagittis est, vel fermentum risus
accumsan
at. Vestibulum sollicitudin dui pharetra sapien volutpat, id convallis mi vestibulum. Phasellus commodo
sit
amet lorem quis imperdiet. Proin nec diam sed urna euismod ultricies at sed urna. Quisque ornare, nulla
et
vehicula ultrices, massa purus vehicula urna, ac sodales lacus leo vitae mi. Sed congue placerat justo
at
placerat. Aenean suscipit fringilla vehicula. Quisque iaculis orci vitae arcu commodo maximus. Maecenas
nec
nunc rutrum, cursus elit quis, porttitor sapien. Sed ac hendrerit ipsum, lacinia fringilla velit. Donec
ultricies feugiat dictum.
</div>
<div class="page">
<table class="min-w-full bg-white page">
<thead class="bg-gray-200">
<tr class="text-xs">
<th class="px-2 py-2 text-right border">Cant.</th>
<th class="px-2 py-2 border">Descripción</th>
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
<th class="px-2 py-2 text-right border">Subtotal</th>
<th class="px-2 py-2 text-right border">Dto (%)</th>
<th class="px-2 py-2 text-right border">Importe total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">{{quantity}}</td>
<td class="px-2 py-2 font-medium">{{description}}</td>
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</main>
<footer>
<table id="total">
<thead>
<tr>
<th>Due by</th>
<th>Account number</th>
<th>Total due</th>
</tr>
</thead>
<tbody>
<tr>
<td>May 10, 2018</td>
<td>132 456 789 012</td>
<td>€10,545.00</td>
</tr>
</tbody>
</table>
</footer>
</body>
</html>

View File

@ -1,136 +0,0 @@
@font-face {
font-family: Pacifico;
src: url(pacifico.ttf);
}
@font-face {
font-family: Source Sans Pro;
font-weight: 400;
src: url(sourcesanspro-regular.otf);
}
@font-face {
font-family: Source Sans Pro;
font-weight: 700;
src: url(sourcesanspro-bold.otf);
}
@page {
font-family: Pacifico;
margin: 3cm;
@bottom-left {
color: #1ee494;
content: "♥ Thank you!";
}
@bottom-right {
color: #a9a;
content: "contact@courtbouillon.org | courtbouillon.org";
font-size: 9pt;
}
}
.fullpage {
page: full;
}
html {
color: #14213d;
font-family: Source Sans Pro;
font-size: 11pt;
line-height: 1.6;
}
body {
margin: 0;
}
h1 {
color: #1ee494;
font-family: Pacifico;
font-size: 40pt;
margin: 0;
}
aside {
display: flex;
margin: 2em 0 4em;
}
aside address {
font-style: normal;
white-space: pre-line;
}
aside address#from {
color: #a9a;
flex: 1;
}
aside address#to {
text-align: right;
}
dl {
position: absolute;
right: 0;
text-align: right;
top: 0;
}
dt,
dd {
display: inline;
margin: 0;
}
dt {
color: #a9a;
}
dt::before {
content: "";
display: block;
}
dt::after {
content: ":";
}
table {
border-collapse: collapse;
width: 100%;
}
th {
border-bottom: 0.2mm solid #a9a;
color: #a9a;
font-size: 10pt;
font-weight: 400;
padding-bottom: 0.25cm;
text-transform: uppercase;
}
td {
padding-top: 7mm;
}
td:last-of-type {
color: #1ee494;
font-weight: bold;
text-align: right;
}
th,
td {
text-align: center;
}
th:first-of-type,
td:first-of-type {
text-align: left;
}
th:last-of-type,
td:last-of-type {
text-align: right;
}
footer {
content: "";
display: block;
height: 6cm;
}
table#total {
background: #f6f6f6;
border-color: #f6f6f6;
border-style: solid;
border-width: 2cm 3cm;
bottom: 0;
font-size: 20pt;
margin: 0 -3cm;
position: absolute;
width: 18cm;
}

View File

@ -1,267 +0,0 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presupuesto #{{id}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer" />
<style type="text/css">
header,
.page-header,
.page-header-space {
height: 300px;
}
footer,
.page-footer,
.page-footer-space {
height: 50px;
}
footer,
.page-footer {
position: fixed;
bottom: 0;
width: 100%;
border-top: 1px solid black;
/* for demo */
background: transparent;
/* for demo */
}
header,
.page-header {
position: fixed;
top: 0mm;
width: 100%;
border-bottom: 1px solid black;
/* for demo */
background: transparent;
/* for demo */
}
.page {
page-break-after: avoid;
}
@page {
margin: 20mm
}
@media print {
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
button {
display: none;
}
body {
margin: 0;
}
}
</style>
</head>
<body class="bg-white">
<div class="page-header">
<div class="flex items-center justify-between pb-4 mb-4 border-b">
<div class="bg-red-400 border border-green-500">
<h1 class="text-2xl font-bold">DISTRIBUIDOR OFICIAL</h1>
<div class="flex space-x-4 bg-blue-500 border">
<div class="mt-2">
<img src="https://via.placeholder.com/100x50" alt="Logo distribuidor" />
</div>
<div class="mt-2 text-sm">
{{dealer.contact_information}}
</div>
</div>
</div>
<div class="text-right">
<img src="https://via.placeholder.com/150x50" alt="Uecko Logo" />
<p class="text-xs text-gray-500">Essential Furniture</p>
<p class="text-xs text-gray-500">PREMIO AMBARRO DEL AÑO 2021</p>
<p class="text-xs text-gray-500">LUXURY SPAIN</p>
<p class="text-xs text-gray-500">ELLE 2021</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
<div>
<p class="text-sm"><strong>Presupuesto nº:</strong> {{id}}</p>
<p class="text-sm"><strong>Fecha:</strong> {{date}}</p>
<p class="text-sm"><strong>Validez:</strong> {{validity}}</p>
<p class="text-sm"><strong>Vendedor:</strong> {{dealer.name}}</p>
<p class="text-sm"><strong>Referencia cliente:</strong> {{reference}}</p>
</div>
<div>
<p class="text-sm">{{customer_information}}</p>
</div>
</div>
<div class="mb-4">
<h2 class="mb-2 text-xl font-semibold">Cotización</h2>
</div>
</div>
<div class="page-footer">
<div class="pt-4 mt-4 border-t">
<p class="text-xs text-gray-500">Información básica sobre protección de datos</p>
<p class="text-xs text-gray-500">{{quote.default_legal_terms}}</p>
</div>
</div>
<table>
<thead>
<tr>
<th class="page-header-space">
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="page-content">
<div class="page">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tincidunt metus eu consectetur rutrum.
Praesent tempor facilisis dapibus. Aliquam cursus diam ac vehicula pulvinar. Integer lacinia non odio et
condimentum. Aenean faucibus cursus mi, sed interdum turpis sagittis a. Quisque quis pellentesque mi. Ut
erat eros, posuere sed scelerisque ut, pharetra vitae tellus. Suspendisse ligula sapien, laoreet ac
hendrerit sit amet, viverra vel mi. Pellentesque faucibus nisl et dolor pharetra, vel mattis massa
venenatis. Integer congue condimentum nisi, sed tincidunt velit tincidunt non. Nulla sagittis sed lorem
pretium aliquam. Praesent consectetur volutpat nibh, quis pulvinar est volutpat id. Cras maximus odio
posuere suscipit venenatis. Donec rhoncus scelerisque metus, in tempus erat rhoncus sed. Morbi massa
sapien,
porttitor id urna vel, volutpat blandit velit. Cras sit amet sem eros. Quisque commodo facilisis
tristique.
Proin pellentesque sodales rutrum. Vestibulum purus neque, congue vel dapibus in, venenatis ut felis.
Donec
et ligula enim. Sed sapien sapien, tincidunt vitae lectus quis, ultricies rhoncus mi. Nunc dapibus nulla
tempus nunc interdum, sed facilisis ex pellentesque. Nunc vel lorem leo. Cras pharetra sodales metus. Cras
lacus ex, consequat at consequat vel, laoreet ac dui. Curabitur aliquam, sapien quis congue feugiat, nisi
nisl feugiat diam, sed vehicula velit nulla ac nisl. Aliquam quis nisi euismod massa blandit pharetra nec
eget nunc. Etiam eros ante, auctor sit amet quam vel, fringilla faucibus leo. Morbi a pulvinar nulla.
Praesent sed vulputate nisl. Orci varius natoque penatibus et magnis dis parturient montes, nascetur
ridiculus mus. Aenean commodo mollis iaculis. Maecenas consectetur enim vitae mollis venenatis. Ut
scelerisque pretium orci id laoreet. In sit amet pharetra diam. Vestibulum in molestie lorem. Nunc
gravida,
eros non consequat fermentum, ex orci vestibulum orci, non accumsan sem velit ac lectus. Vivamus malesuada
lacus nec velit dignissim, ac fermentum nulla pretium. Aenean mi nisi, convallis sed tempor in, porttitor
eu
libero. Praesent et molestie ante. Duis suscipit vitae purus sit amet aliquam. Vestibulum lectus justo,
lobortis a purus a, dapibus efficitur metus. Suspendisse potenti. Duis dictum ex lorem. Suspendisse nec
ligula consectetur magna hendrerit ullamcorper et eget mauris. Etiam vestibulum sodales diam, eget
venenatis
nunc luctus quis. Ut fermentum placerat neque nec elementum. Praesent orci erat, rhoncus vitae est eu,
dictum molestie metus. Cras et fermentum elit. Aenean eget augue lacinia, varius ante in, ullamcorper
dolor.
Cras viverra purus non egestas consectetur. Nulla nec dolor ac lectus convallis aliquet sed a metus.
Suspendisse eu imperdiet nunc, id pulvinar risus. Maecenas varius sagittis est, vel fermentum risus
accumsan
at. Vestibulum sollicitudin dui pharetra sapien volutpat, id convallis mi vestibulum. Phasellus commodo
sit
amet lorem quis imperdiet. Proin nec diam sed urna euismod ultricies at sed urna. Quisque ornare, nulla et
vehicula ultrices, massa purus vehicula urna, ac sodales lacus leo vitae mi. Sed congue placerat justo at
placerat. Aenean suscipit fringilla vehicula. Quisque iaculis orci vitae arcu commodo maximus. Maecenas
nec
nunc rutrum, cursus elit quis, porttitor sapien. Sed ac hendrerit ipsum, lacinia fringilla velit. Donec
ultricies feugiat dictum.
</div>
<div class="page">
<table class="min-w-full bg-white page">
<thead class="bg-gray-200">
<tr class="text-xs">
<th class="px-2 py-2 text-right border">Cant.</th>
<th class="px-2 py-2 border">Descripción</th>
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
<th class="px-2 py-2 text-right border">Subtotal</th>
<th class="px-2 py-2 text-right border">Dto (%)</th>
<th class="px-2 py-2 text-right border">Importe total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">{{quantity}}</td>
<td class="px-2 py-2 font-medium">{{description}}</td>
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<div class="page"></div>
<div class="flex items-center justify-between pb-4 mb-4 border-b">
<div>
<div class="pt-4 border-t">
<p class="text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
<div class="pt-4 border-t">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
</div>
<div>
<table class="min-w-full bg-transparent">
<tbody>
<tr class="border-b">
<td class="px-4 py-2 border">Importe neto</td>
<td class="px-4 py-2 border"></td>
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">% Descuento</td>
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">Base imponible</td>
<td class="px-4 py-2 border"></td>
<td class="px-4 py-2 text-right border">{{before_tax_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">% IVA</td>
<td class="px-4 py-2 text-right border">{{tax}}</td>
<td class="px-4 py-2 text-right border">{{tax_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2 border">Importe total</td>
<td class="px-4 py-2 border"></td>
<td class="px-4 py-2 text-right border">{{total_price}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>
<div class="page-footer-space"></div>
</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@ -62,11 +62,11 @@
</section>
<section class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
<aside>
<p class="text-sm"><strong>Presupuesto nº:</strong> {{id}}</p>
<p class="text-sm"><strong>Presupuesto nº:</strong> {{reference}}</p>
<p class="text-sm"><strong>Fecha:</strong> {{date}}</p>
<p class="text-sm"><strong>Validez:</strong> {{validity}}</p>
<p class="text-sm"><strong>Vendedor:</strong> {{dealer.name}}</p>
<p class="text-sm"><strong>Referencia cliente:</strong> {{reference}}</p>
<p class="text-sm"><strong>Referencia cliente:</strong> {{customer_reference}}</p>
</aside>
<address class="text-base not-italic font-semibold whitespace-pre-line" id="to">{{customer_information}}
</address>

View File

@ -9,7 +9,13 @@ import {
} from "@shared/contexts";
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import { IQuoteProps, Quote, QuoteCustomer, QuoteReference } from "../../domain";
import {
IQuoteProps,
Quote,
QuoteCustomer,
QuoteCustomerReference,
QuoteReference,
} from "../../domain";
import { QuoteStatus } from "../../domain/entities/Quotes/QuoteStatus";
import { ISalesContext } from "../Sales.context";
import { QuoteCreationAttributes, Quote_Model } from "../sequelize";
@ -45,6 +51,11 @@ class QuoteMapper
reference: this.mapsValue(source, "reference", QuoteReference.create),
currency: this.mapsValue(source, "currency_code", CurrencyData.createFromCode),
language: this.mapsValue(source, "lang_code", Language.createFromCode),
customerReference: this.mapsValue(
source,
"customer_reference",
QuoteCustomerReference.create
),
customer: this.mapsValue(source, "customer_information", QuoteCustomer.create),
validity: this.mapsValue(source, "validity", Note.create),
@ -108,6 +119,7 @@ class QuoteMapper
reference: source.reference.toPrimitive(),
currency_code: source.currency.toPrimitive(),
lang_code: source.language.toPrimitive(),
customer_reference: source.customerReference.toPrimitive(),
customer_information: source.customer.toPrimitive(),
validity: source.validity.toPrimitive(),
payment_method: source.paymentMethod.toPrimitive(),

View File

@ -44,6 +44,7 @@ export class Quote_Model extends Model<
declare date: CreationOptional<string>;
declare reference: CreationOptional<string>;
declare lang_code: CreationOptional<string>;
declare customer_reference: CreationOptional<string>;
declare customer_information: CreationOptional<string>;
declare currency_code: CreationOptional<string>;
declare payment_method: CreationOptional<string>;
@ -100,6 +101,10 @@ export default (sequelize: Sequelize) => {
defaultValue: "EUR",
},
customer_reference: {
type: new DataTypes.STRING(),
},
customer_information: {
type: DataTypes.TEXT,
},
@ -179,6 +184,9 @@ export default (sequelize: Sequelize) => {
reference: {
[Op.like]: `%${value}%`,
},
customer_reference: {
[Op.like]: `%${value}%`,
},
customer_information: {
[Op.like]: `%${value}%`,
},

View File

@ -40,4 +40,22 @@ export class Name extends StringValueObject {
return Result.ok(new Name(validationResult.object));
}
public static generateAcronym(name: string): string {
const words = name.split(" ").map((word) => word[0].toUpperCase());
let acronym = words.join("");
// Asegurarse de que tenga 4 caracteres, recortando o añadiendo letras
if (acronym.length > 4) {
acronym = acronym.slice(0, 4);
} else if (acronym.length < 4) {
acronym = acronym.padEnd(4, "X"); // Se completa con 'X' si es necesario
}
return acronym;
}
public getAcronym(): string {
return Name.generateAcronym(this.toString());
}
}

View File

@ -11,7 +11,7 @@ export interface ICreateQuote_Request_DTO {
id: string;
status: string;
date: string;
reference: string;
customer_reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
@ -45,6 +45,7 @@ export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Requ
status: Joi.string(),
date: Joi.string(),
reference: Joi.string(),
customer_reference: Joi.string(),
customer_information: Joi.string(),
}).unknown(true);

View File

@ -5,6 +5,7 @@ export interface ICreateQuote_Response_DTO {
status: string;
date: string;
reference: string;
customer_reference: string;
customer_information: string;
lang_code: string;
currency_code: string;

View File

@ -5,6 +5,7 @@ export interface IGetQuote_Response_DTO {
status: string;
date: string;
reference: string;
customer_reference: string;
customer_information: string;
lang_code: string;
currency_code: string;

View File

@ -5,6 +5,7 @@ export interface IListQuotes_Response_DTO {
status: string;
date: string;
reference: string;
customer_reference: string;
customer_information: string;
lang_code: string;
currency_code: string;

View File

@ -1,4 +0,0 @@
export interface IReportQuote_Response_DTO {
data: Uint8Array;
original: ArrayBuffer;
}

View File

@ -1 +0,0 @@
export * from "./IReportQuote_Response.dto";

View File

@ -11,6 +11,7 @@ export interface IUpdateQuote_Request_DTO {
status: string;
date: string;
reference: string;
customer_reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
@ -43,6 +44,7 @@ export function ensureUpdateQuote_Request_DTOIsValid(quoteDTO: IUpdateQuote_Requ
status: Joi.string(),
date: Joi.string(),
reference: Joi.string(),
customer_reference: Joi.string(),
customer_information: Joi.string(),
lang_code: Joi.string(),
currency_code: Joi.string(),

View File

@ -5,6 +5,7 @@ export interface IUpdateQuote_Response_DTO {
status: string;
date: string;
reference: string;
customer_reference: string;
customer_information: string;
lang_code: string;
currency_code: string;

View File

@ -1,5 +1,4 @@
export * from "./CreateQuote.dto";
export * from "./GetQuote.dto";
export * from "./ListQuotes.dto";
export * from "./ReportQuote.dto";
export * from "./UpdateQuote.dto";