.
This commit is contained in:
parent
9931a4a839
commit
26bc608131
@ -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",
|
||||
|
||||
@ -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,
|
||||
})}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' />
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
1
server/src/contexts/sales/application/services/index.ts
Normal file
1
server/src/contexts/sales/application/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./QuoteService";
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./Quote";
|
||||
export * from "./QuoteCustomer";
|
||||
export * from "./QuoteCustomerReference";
|
||||
export * from "./QuoteItem";
|
||||
export * from "./QuoteReference";
|
||||
export * from "./QuoteStatus";
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./entities";
|
||||
export * from "./repository";
|
||||
export * from "./services";
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import { IApplicationService } from "@/contexts/common/application";
|
||||
|
||||
export interface IQuoteReferenceGeneratorService extends IApplicationService {}
|
||||
1
server/src/contexts/sales/domain/services/index.ts
Normal file
1
server/src/contexts/sales/domain/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./QuoteReferenceGeneratorService.interface";
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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}%`,
|
||||
},
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
export interface IReportQuote_Response_DTO {
|
||||
data: Uint8Array;
|
||||
original: ArrayBuffer;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./IReportQuote_Response.dto";
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
export * from "./CreateQuote.dto";
|
||||
export * from "./GetQuote.dto";
|
||||
export * from "./ListQuotes.dto";
|
||||
export * from "./ReportQuote.dto";
|
||||
export * from "./UpdateQuote.dto";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user