Añadir el IVA como un campo más en ajustes

This commit is contained in:
David Arranz 2024-08-16 16:57:44 +02:00
parent 990e0850f2
commit 0ff5c39023
20 changed files with 263 additions and 107 deletions

View File

@ -1,27 +1,37 @@
import { FormPercentageField } from "@/components";
import { useLocalization } from "@/lib/hooks";
import { Card, CardContent, CardDescription, CardTitle, Separator } from "@/ui";
import { CurrencyData } from "@shared/contexts";
import { t } from "i18next";
import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
export const QuotePricesResume = () => {
const { watch, register, formState } = useFormContext();
const { formatNumber } = useLocalization();
const currency_code = watch("currency_code");
const subtotal_price = formatNumber(watch("subtotal_price"));
const discount_price = formatNumber(watch("discount_price"));
const tax_price = formatNumber(watch("tax_price"));
const total_price = formatNumber(watch("total_price"));
const currency_symbol = useMemo(() => {
const currencyOrError = CurrencyData.createFromCode(currency_code);
return currencyOrError.isSuccess ? currencyOrError.object.symbol : "";
}, [currency_code]);
return (
<Card className='w-full'>
<CardContent className='flex flex-row items-end gap-2 p-4 border-t'>
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm'>Importe neto</CardDescription>
<CardDescription className='text-sm'>
{t("quotes.form_fields.subtotal_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline text-2xl tabular-nums'>
{subtotal_price}
<span className='ml-1 text-lg tracking-normal'></span>
<span className='ml-1 text-lg tracking-normal'>{currency_symbol}</span>
</CardTitle>
</div>
</div>
@ -41,10 +51,12 @@ export const QuotePricesResume = () => {
/>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm'>Imp. descuento</CardDescription>
<CardDescription className='text-sm'>
{t("quotes.form_fields.discount_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline text-2xl tabular-nums'>
{discount_price}
<span className='ml-1 text-lg tracking-normal'></span>
<span className='ml-1 text-lg tracking-normal'>{currency_symbol}</span>
</CardTitle>
</div>
</div>
@ -65,20 +77,24 @@ export const QuotePricesResume = () => {
/>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm'>Importe IVA</CardDescription>
<CardDescription className='text-sm'>
{t("quotes.form_fields.tax_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline gap-1 text-2xl tabular-nums'>
{tax_price}
<span className='text-base font-medium tracking-normal'></span>
<span className='text-base font-medium tracking-normal'>{currency_symbol}</span>
</CardTitle>
</div>
</div>{" "}
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-0'>
<CardDescription className='text-sm font-semibold'>Importe total</CardDescription>
<CardDescription className='text-sm font-semibold'>
{t("quotes.form_fields.total_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline gap-1 text-3xl tabular-nums'>
{total_price}
<span className='ml-1 text-lg tracking-normal'></span>
<span className='ml-1 text-lg tracking-normal'>{currency_symbol}</span>
</CardTitle>
</div>
</div>

View File

@ -15,7 +15,6 @@ import { useQuotes } from "../hooks";
export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
const navigate = useNavigate();
const { pagination, globalFilter, isFiltered } = useDataTableContext();
const { useList } = useQuotes();
const { data, isPending, isError, error } = useList({
@ -48,7 +47,12 @@ export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
<div className='text-ellipsis'>
{original.customer_information.split("\n").map((item, index) => {
return (
<span key={index} className={index === 0 ? "font-semibold" : "font-medium"}>
<span
key={index}
className={
index === 0 ? "font-medium" : "hidden text-sm text-muted-foreground md:inline"
}
>
{item}
<br />
</span>
@ -78,7 +82,7 @@ export const QuotesDataTable = ({ status = "all" }: { status?: string }) => {
id: "total_price" as const,
accessor: "total_price",
header: () => <div className='text-right'>{t("quotes.list.columns.total_price")}</div>,
cell: ({ table, row: { index, original }, column, getValue }) => {
cell: ({ row: { original } }) => {
const price = MoneyValue.create(original.total_price);
return (
<div className='text-right'>{price.isSuccess ? price.object.toFormat() : "-"}</div>

View File

@ -1,4 +1,4 @@
import { ErrorOverlay, FormTextAreaField, LoadingOverlay } from "@/components";
import { ErrorOverlay, FormPercentageField, FormTextAreaField, LoadingOverlay } from "@/components";
import {
Alert,
AlertDescription,
@ -12,6 +12,7 @@ import {
CardTitle,
Form,
} from "@/ui";
import { joiResolver } from "@hookform/resolvers/joi";
import { t } from "i18next";
import { AlertCircleIcon } from "lucide-react";
@ -22,13 +23,14 @@ import { Trans } from "react-i18next";
import { useUnsavedChangesNotifier } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { IUpdateProfile_Request_DTO } from "@shared/contexts";
import Joi from "joi";
import { toast } from "react-toastify";
import { useSettings } from "./hooks";
type SettingsDataForm = IUpdateProfile_Request_DTO;
export const SettingsEditor = () => {
const [activeSection, setActiveSection] = useState("profile");
const [activeSection, setActiveSection] = useState("quotes");
const { useOne, useUpdate } = useSettings();
const { data, status, error: queryError } = useOne();
@ -40,6 +42,10 @@ export const SettingsEditor = () => {
default_notes: "",
default_legal_terms: "",
default_quote_validity: "",
default_tax: {
amount: undefined,
scale: 2,
},
}),
[]
);
@ -50,18 +56,19 @@ export const SettingsEditor = () => {
mode: "onBlur",
values: data?.dealer,
defaultValues,
//defaultValues: _defaultValues,
/*resolver: joiResolver(
resolver: joiResolver(
Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
password: Joi.string().min(4).alphanum().required(),
}),
{
messages: SpanishJoiMessages,
}
),*/
contact_information: Joi.string().optional().allow(null).allow("").default(""),
default_payment_method: Joi.string().optional().allow(null).allow("").default(""),
default_notes: Joi.string().optional().allow(null).allow("").default(""),
default_legal_terms: Joi.string().optional().allow(null).allow("").default(""),
default_quote_validity: Joi.string().optional().allow(null).allow("").default(""),
default_tax: Joi.object({
amount: Joi.number().allow(null),
scale: Joi.number(),
}).required(),
})
),
});
const { formState, reset, getValues, handleSubmit } = form;
@ -159,10 +166,8 @@ export const SettingsEditor = () => {
//autoSize
rows={8}
placeholder={t("settings.form_fields.contact_information.placeholder")}
{...form.register("contact_information", {
required: true,
})}
errors={form.formState.errors}
name='contact_information'
required
/>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>
@ -173,6 +178,31 @@ export const SettingsEditor = () => {
</Card>
</div>
<div className={cn("grid gap-6", activeSection === "quotes" ? "visible" : "hidden")}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey='settings.form_fields.default_tax.label' />
</CardTitle>
<CardDescription>
<Trans i18nKey='settings.form_fields.default_tax.desc' />
</CardDescription>
</CardHeader>
<CardContent>
<FormPercentageField
scale={2}
disabled={formState.disabled}
placeholder={t("settings.form_fields.default_tax.desc")}
name='default_tax'
required
/>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>
<Button>
<Trans i18nKey='common.save' />
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>
@ -186,9 +216,8 @@ export const SettingsEditor = () => {
<FormTextAreaField
autoSize
placeholder={t("settings.form_fields.default_payment_method.placeholder")}
{...form.register("default_payment_method", {
required: true,
})}
name='default_payment_method'
required
errors={form.formState.errors}
/>
</CardContent>
@ -211,10 +240,8 @@ export const SettingsEditor = () => {
<FormTextAreaField
autoSize
placeholder={t("settings.form_fields.default_quote_validity.placeholder")}
{...form.register("default_quote_validity", {
required: true,
})}
errors={form.formState.errors}
name='default_quote_validity'
required
/>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>
@ -237,10 +264,8 @@ export const SettingsEditor = () => {
<FormTextAreaField
autoSize
placeholder={t("settings.form_fields.default_notes.placeholder")}
{...form.register("default_notes", {
required: true,
})}
errors={form.formState.errors}
name='default_notes'
required
/>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>
@ -265,10 +290,8 @@ export const SettingsEditor = () => {
//autoSize
rows={25}
placeholder={t("settings.form_fields.default_legal_terms.placeholder")}
{...form.register("default_legal_terms", {
required: true,
})}
errors={form.formState.errors}
name='default_legal_terms'
required
/>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>

View File

@ -106,7 +106,7 @@ export function DataTable<TData>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={rowClassName}
className={cn(row.getIsSelected() ? "bg-accent" : "", rowClassName)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={cellClassName}>

View File

@ -26,12 +26,7 @@ export function DataTableColumnHeader<TData, TValue>({
if (!header.column.getCanSort()) {
return (
<>
<div
className={cn(
"data-[state=open]:bg-accent font-bold text-muted-foreground uppercase text-xs tracking-wide text-ellipsis",
className
)}
>
<div className={cn("data-[state=open]:bg-accent tracking-wide text-ellipsis", className)}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}

View File

@ -194,21 +194,41 @@
"placeholder": "",
"desc": "Quote's validity time"
},
"discount": {
"label": "Discount",
"placeholder": "%",
"desc": "Percentage discount"
},
"tax": {
"label": "Tax",
"placeholder": "%",
"desc": "Percentage Tax"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"desc": "Quote subtotal"
},
"discount": {
"label": "Discount (%)",
"placeholder": "",
"desc": "Percentage discount"
},
"discount_price": {
"label": "Discount price",
"placeholder": "",
"desc": "Percentage discount price"
},
"before_tax_price": {
"label": "Before tax price",
"placeholder": "",
"desc": "Before tax price"
},
"tax": {
"label": "Tax (%)",
"placeholder": "",
"desc": "Percentage Tax"
},
"tax_price": {
"label": "Tax price",
"placeholder": "",
"desc": "Percentage tax price"
},
"total_price": {
"label": "Total price",
"placeholder": "",
"desc": "Quote total price"
},
"items": {
"quantity": {
"label": "Quantity",
@ -256,14 +276,19 @@
"form_fields": {
"image": {
"label": "Logotype",
"placeholder": "placeholder",
"placeholder": "",
"desc": "Información de contacto"
},
"contact_information": {
"label": "Your contact information",
"placeholder": "placeholder",
"placeholder": "",
"desc": "Your contact information as a dealer that will appear on the quotes given to your customers."
},
"default_tax": {
"label": "Default tax (%)",
"placeholder": "",
"desc": "Default tax rate for your quotes"
},
"default_legal_terms": {
"label": "Legal terms",
"placeholder": "",
@ -271,7 +296,7 @@
},
"default_payment_method": {
"label": "Payment method",
"placeholder": "placeholder",
"placeholder": "",
"desc": "Default payment method to be used for new quotes"
},
"default_notes": {

View File

@ -194,21 +194,41 @@
"placeholder": "",
"desc": "desc"
},
"discount": {
"label": "Descuento",
"placeholder": "%",
"desc": "Porcentaje de descuento"
},
"tax": {
"label": "IVA",
"placeholder": "%",
"desc": "Porcentaje de IVA"
},
"subtotal_price": {
"label": "Importe neto",
"placeholder": "",
"desc": ""
},
"discount": {
"label": "Descuento (%)",
"placeholder": "",
"desc": "Porcentaje de descuento"
},
"discount_price": {
"label": "Imp. descuento",
"placeholder": "",
"desc": "Importe del descuento"
},
"before_tax_price": {
"label": "Base imponible",
"placeholder": "",
"desc": ""
},
"tax": {
"label": "IVA (%)",
"placeholder": "",
"desc": "Porcentaje de IVA"
},
"tax_price": {
"label": "Imp. descuento",
"placeholder": "",
"desc": "Importe del descuento"
},
"total_price": {
"label": "Total price",
"placeholder": "",
"desc": "Quote total price"
},
"items": {
"quantity": {
"label": "Cantidad",
@ -244,37 +264,39 @@
}
},
"settings": {
"title": "Ajustes",
"profile": {
"title": "Ajustes de perfil",
"items": {
"image": {
"label": "Información de contacto",
"placeholder": "placeholder",
"desc": "Información de contacto"
},
"contact_information": {
"label": "Información de contacto",
"placeholder": "placeholder",
"desc": "Información de contacto"
}
"edit": {
"title": "Ajustes",
"subtitle": "",
"tabs": {
"profile": "Ajustes de perfil",
"quotes": "Ajustes legales",
"legal": "Ajustes para cotizaciones"
}
},
"legal": {
"title": "Ajustes legales",
"items": {
"default_legal_terms": {
"label": "Cláusulas legales",
"placeholder": "",
"desc": "desc"
}
}
},
"quotes": {
"title": "Ajustes para cotizaciones",
"form_fields": {
"image": {
"label": "Información de contacto",
"placeholder": "",
"desc": "Información de contacto"
},
"contact_information": {
"label": "Información de contacto",
"placeholder": "",
"desc": "Información de contacto"
},
"default_tax": {
"label": "IVA por defecto (%)",
"placeholder": "",
"desc": "Porcentaje de IVA por defecto para tus cotizaciones"
},
"default_legal_terms": {
"label": "Cláusulas legales",
"placeholder": "",
"desc": "desc"
},
"default_payment_method": {
"label": "Forma de pago",
"placeholder": "placeholder",
"placeholder": "",
"desc": "desc"
},
"default_notes": {

View File

@ -7,7 +7,13 @@ import {
import { IRepositoryManager } from "@/contexts/common/domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { IUpdateProfile_Request_DTO, Result, TextValueObject, UniqueID } from "@shared/contexts";
import {
IUpdateProfile_Request_DTO,
Percentage,
Result,
TextValueObject,
UniqueID,
} from "@shared/contexts";
import { IProfileRepository, Profile } from "../domain";
export interface IUpdateProfileUseCaseRequest extends IUseCaseRequest {
@ -33,8 +39,6 @@ export class UpdateProfileUseCase
async execute(request: IUpdateProfileUseCaseRequest): Promise<UpdateProfileResponseOrError> {
const { userId, profileDTO } = request;
console.log(request);
// Comprobar que existe el profile
const exitsOrError = await this._getProfileDealer(userId);
if (exitsOrError.isFailure) {
@ -55,6 +59,13 @@ export class UpdateProfileUseCase
profile.defaultNotes = TextValueObject.create(profileDTO.default_notes).object;
profile.defaultQuoteValidity = TextValueObject.create(profileDTO.default_quote_validity).object;
const taxOrError = Percentage.create(profileDTO.default_tax);
if (taxOrError.isFailure) {
return Result.fail(taxOrError.error);
}
profile.defaultTax = taxOrError.object;
// Guardar los cambios
return this._saveProfile(profile);
}

View File

@ -6,6 +6,7 @@ import {
Language,
Name,
Note,
Percentage,
Result,
UniqueID,
} from "@shared/contexts";
@ -21,6 +22,7 @@ export interface IProfileProps {
defaultNotes: Note;
defaultLegalTerms: Note;
defaultQuoteValidity: Note;
defaultTax: Percentage;
}
export interface IProfile {
@ -34,7 +36,9 @@ export interface IProfile {
defaultPaymentMethod: Note;
defaultNotes: Note;
defaultLegalTerms: Note;
defaultQuoteValidity: Note;
defaultTax: Percentage;
}
export class Profile extends AggregateRoot<IProfileProps> implements IProfile {
@ -98,4 +102,12 @@ export class Profile extends AggregateRoot<IProfileProps> implements IProfile {
set defaultQuoteValidity(newDefaultQuoteValidity: Note) {
this.props.defaultQuoteValidity = newDefaultQuoteValidity;
}
get defaultTax(): Percentage {
return this.props.defaultTax;
}
set defaultTax(newDefaultTax: Percentage) {
this.props.defaultTax = newDefaultTax;
}
}

View File

@ -27,6 +27,7 @@ export const GetProfilePresenter: IGetProfilePresenter = {
default_notes: profile.defaultNotes.toString(),
default_legal_terms: profile.defaultLegalTerms.toString(),
default_quote_validity: profile.defaultQuoteValidity.toString(),
default_tax: profile.defaultTax.convertScale(2).toObject(),
},
};
},

View File

@ -15,6 +15,7 @@ export const UpdateProfilePresenter: IUpdateProfilePresenter = {
default_notes: profile.defaultNotes.toString(),
default_legal_terms: profile.defaultLegalTerms.toString(),
default_quote_validity: profile.defaultQuoteValidity.toString(),
default_tax: profile.defaultTax.convertScale(2).toObject(),
};
},
};

View File

@ -5,7 +5,14 @@ import {
} from "@/contexts/common/infrastructure";
import { DealerStatus } from "@/contexts/sales/domain";
import { Dealer_Model, DealerCreationAttributes } from "@/contexts/sales/infrastructure/sequelize";
import { CurrencyData, Language, Name, TextValueObject, UniqueID } from "@shared/contexts";
import {
CurrencyData,
Language,
Name,
Percentage,
TextValueObject,
UniqueID,
} from "@shared/contexts";
import { IProfileProps, Profile } from "../../domain";
import { IProfileContext } from "../Profile.context";
@ -44,6 +51,13 @@ class ProfileMapper
TextValueObject.create
);
const defaultTax = this.mapsValue(source, "default_tax", (tax) =>
Percentage.create({
amount: tax,
scale: 2,
})
);
const props: IProfileProps = {
name,
status,
@ -55,6 +69,7 @@ class ProfileMapper
defaultNotes,
defaultLegalTerms,
defaultQuoteValidity,
defaultTax,
};
const id = this.mapsValue(source, "id", UniqueID.create);
@ -76,6 +91,7 @@ class ProfileMapper
default_notes: source.defaultNotes.toPrimitive(),
default_legal_terms: source.defaultLegalTerms.toPrimitive(),
default_quote_validity: source.defaultQuoteValidity.toPrimitive(),
default_tax: source.defaultTax.convertScale(2).toPrimitive(),
};
}
}

View File

@ -41,6 +41,8 @@ export class ListQuotesUseCase implements IUseCase<IListQuotesParams, Promise<Li
);
}
console.log(queryCriteria?.toJSON());
return this.findQuotes(queryCriteria);
}

View File

@ -87,9 +87,17 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
return this.mapper.mapToDomain(rawQuote);
}
public async findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<any>> {
public async findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Quote>> {
const QuoteItem_Model: ModelDefined<any, any> = this._adapter.getModel("QuoteItem_Model");
const { rows, count } = await this._findAll("Quote_Model", queryCriteria, {
include: [], // esto es para quitar las asociaciones al hacer la consulta
/*include: [
{
model: QuoteItem_Model,
as: "items",
},
],*/
order: [
["date", "DESC"],
["customer_information", "ASC"],

View File

@ -32,6 +32,7 @@ class DealerMapper
["default_notes", source.default_notes],
["default_legal_terms", source.default_legal_terms],
["default_quote_validity", source.default_quote_validity],
["default_tax", source.default_tax],
]);
const props: IDealerProps = {
@ -69,6 +70,7 @@ class DealerMapper
default_notes: source.additionalInfo.get("default_notes")?.toString() ?? "",
default_legal_terms: source.additionalInfo.get("default_legal_terms")?.toString() ?? "",
default_quote_validity: source.additionalInfo.get("default_quote_validity")?.toString() ?? "",
default_tax: source.additionalInfo.get("default_tax")?.toString() ?? "",
};
}
}

View File

@ -53,6 +53,7 @@ export class Dealer_Model extends Model<
declare default_notes: CreationOptional<string>;
declare default_legal_terms: CreationOptional<string>;
declare default_quote_validity: CreationOptional<string>;
declare default_tax: CreationOptional<number | null>;
declare status: CreationOptional<string>;
declare lang_code: CreationOptional<string>;
declare currency_code: CreationOptional<string>;
@ -85,6 +86,12 @@ export default (sequelize: Sequelize) => {
default_legal_terms: DataTypes.TEXT,
default_quote_validity: DataTypes.TEXT,
default_tax: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: 2100,
},
lang_code: {
type: DataTypes.STRING(2),
allowNull: false,

View File

@ -1,5 +1,5 @@
export const INITIAL_PAGE_INDEX = 0;
export const INITIAL_PAGE_SIZE = 5;
export const INITIAL_PAGE_SIZE = 10;
export const MIN_PAGE_INDEX = 0;
export const MIN_PAGE_SIZE = 1;

View File

@ -1,3 +1,5 @@
import { IPercentage_DTO } from "../../../../common";
export interface IGetProfileResponse_DTO {
id: string;
name: string;
@ -12,6 +14,7 @@ export interface IGetProfileResponse_DTO {
default_notes: string;
default_legal_terms: string;
default_quote_validity: string;
default_tax: IPercentage_DTO;
status: string;
lang_code: string;
currency_code: string;

View File

@ -1,5 +1,5 @@
import Joi from "joi";
import { Result, RuleValidator } from "../../../../common";
import { IPercentage_DTO, Result, RuleValidator } from "../../../../common";
export interface IUpdateProfile_Request_DTO {
contact_information: string;
@ -7,6 +7,7 @@ export interface IUpdateProfile_Request_DTO {
default_notes: string;
default_legal_terms: string;
default_quote_validity: string;
default_tax: IPercentage_DTO;
}
export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) {
@ -16,6 +17,10 @@ export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_R
default_notes: Joi.string().optional().allow(null).allow("").default(""),
default_legal_terms: Joi.string().optional().allow(null).allow("").default(""),
default_quote_validity: Joi.string().optional().allow(null).allow("").default(""),
default_tax: Joi.object({
amount: Joi.number().allow(null),
scale: Joi.number(),
}).optional(),
}).unknown(true);
const result = RuleValidator.validate<IUpdateProfile_Request_DTO>(schema, userDTO);

View File

@ -1,3 +1,5 @@
import { IPercentage_DTO } from "../../../../common";
export interface IUpdateProfileResponse_DTO {
dealer_id: string;
contact_information: string;
@ -5,4 +7,5 @@ export interface IUpdateProfileResponse_DTO {
default_notes: string;
default_legal_terms: string;
default_quote_validity: string;
default_tax: IPercentage_DTO;
}