feat(payment-terms): add payment terms management components and utilities
- Implemented PaymentTermRowActions component for editing, toggling, and deleting payment terms. - Created PaymentTermSegmentedBar for visual representation of payment term segments. - Added PaymentTermsEmptyState and PaymentTermsNoResultsState for handling empty and no results scenarios. - Developed PaymentTermsSettingsSkeleton for loading state representation. - Introduced utility functions for building payment term forms and parameters. - Added criteria building functions for payment terms list filtering. - Implemented error handling view model for payment terms settings page. - Created tests for payment terms list criteria and due rules utilities. - Added shell script to check development environment setup.
This commit is contained in:
parent
97174e580d
commit
eba766e7eb
26
AGENTS.md
26
AGENTS.md
@ -18,6 +18,32 @@ Trabajar sobre el ERP mediante cambios:
|
||||
|
||||
Antes de modificar código, revisar las implementaciones y patrones existentes en el área afectada. No introducir patrones nuevos si ya existe una solución equivalente en el proyecto.
|
||||
|
||||
## Entorno de ejecución
|
||||
|
||||
El repositorio canónico es:
|
||||
|
||||
`/home/rodax/Documentos/uecko-erp`
|
||||
|
||||
Todos los comandos de desarrollo deben ejecutarse en Linux.
|
||||
|
||||
No ejecutar Node.js, PNPM, Turborepo, TypeScript, Biome ni tests
|
||||
desde rutas Windows, unidades `Z:` ni rutas UNC.
|
||||
|
||||
Antes de trabajar comprobar:
|
||||
|
||||
```bash
|
||||
pwd
|
||||
uname -s
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
Se espera:
|
||||
|
||||
```
|
||||
Linux
|
||||
/home/rodax/Documentos/uecko-erp
|
||||
|
||||
```
|
||||
|
||||
## Stack principal
|
||||
|
||||
- Node.js >= 22
|
||||
|
||||
@ -5,6 +5,7 @@ export type PaymentTermSummary = {
|
||||
id: UniqueID;
|
||||
companyId: UniqueID;
|
||||
name: Name;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
dueRules: Array<PaymentTermDueRule>;
|
||||
|
||||
@ -12,6 +12,7 @@ export class PaymentTermSummarySnapshotBuilder implements IPaymentTermSummarySna
|
||||
id: paymentTerm.id.toString(),
|
||||
company_id: paymentTerm.companyId.toString(),
|
||||
name: paymentTerm.name.toString(),
|
||||
description: paymentTerm.description,
|
||||
is_system: paymentTerm.isSystem,
|
||||
is_active: paymentTerm.isActive,
|
||||
due_rules: paymentTerm.dueRules.map((rule) => ({
|
||||
|
||||
@ -33,7 +33,7 @@ export const paymentTermsRouter = (params: StartParams) => {
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
validateRequest(ListPaymentTermsRequestSchema, "params"),
|
||||
validateRequest(ListPaymentTermsRequestSchema, "query"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new ListPaymentTermsController(deps.useCases.listPaymentTerms());
|
||||
return controller.execute(req, res, next);
|
||||
|
||||
@ -60,6 +60,7 @@ export class SequelizePaymentTermSummaryMapper extends SequelizeQueryMapper<
|
||||
id: id!,
|
||||
companyId: companyId!,
|
||||
name: name!,
|
||||
description: raw.description ?? null,
|
||||
isActive: raw.is_active,
|
||||
isSystem: raw.is_system,
|
||||
dueRules,
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { Op, Sequelize } from "sequelize";
|
||||
|
||||
import { buildPaymentTermQuickSearchWhere } from "./payment-term-quick-search.ts";
|
||||
|
||||
function buildWhereSql(quickSearch: string): string {
|
||||
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", {
|
||||
logging: false,
|
||||
});
|
||||
const queryGenerator = database.getQueryInterface().queryGenerator as {
|
||||
whereQuery: (where: unknown) => string;
|
||||
};
|
||||
|
||||
const where = {
|
||||
[Op.and]: [
|
||||
{ company_id: "company-1" },
|
||||
{ deleted_at: null },
|
||||
buildPaymentTermQuickSearchWhere({ quickSearch }),
|
||||
],
|
||||
};
|
||||
|
||||
return queryGenerator.whereQuery(where);
|
||||
}
|
||||
|
||||
test("busca por name y description usando LIKE", () => {
|
||||
const sql = buildWhereSql("contado");
|
||||
|
||||
assert.match(sql, /`name` LIKE '%contado%'/);
|
||||
assert.match(sql, /`description` LIKE '%contado%'/);
|
||||
assert.doesNotMatch(sql, /MATCH\(/);
|
||||
});
|
||||
|
||||
test("mantiene aislamiento por empresa y soft delete", () => {
|
||||
const sql = buildWhereSql("6");
|
||||
|
||||
assert.match(sql, /`company_id` = 'company-1'/);
|
||||
assert.match(sql, /`deleted_at` IS NULL/);
|
||||
assert.match(sql, /`name` LIKE '%6%'/);
|
||||
});
|
||||
@ -0,0 +1,29 @@
|
||||
import { Op, type WhereOptions } from "sequelize";
|
||||
|
||||
import type { PaymentTermModel } from "../models";
|
||||
|
||||
type BuildPaymentTermQuickSearchWhereParams = {
|
||||
quickSearch?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evita depender de índices FULLTEXT en un listado pequeño de configuración.
|
||||
*
|
||||
* La búsqueda rápida de plazos de pago debe seguir funcionando aunque la tabla
|
||||
* no tenga un índice compuesto `FULLTEXT(name, description)`.
|
||||
*/
|
||||
export function buildPaymentTermQuickSearchWhere(
|
||||
params: BuildPaymentTermQuickSearchWhereParams
|
||||
): WhereOptions<PaymentTermModel> | undefined {
|
||||
const term = params.quickSearch?.trim();
|
||||
|
||||
if (!term) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const likePattern = `%${term}%`;
|
||||
|
||||
return {
|
||||
[Op.or]: [{ name: { [Op.like]: likePattern } }, { description: { [Op.like]: likePattern } }],
|
||||
};
|
||||
}
|
||||
@ -1,15 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { Op } from "sequelize";
|
||||
|
||||
import { PaymentTerm } from "../../../../../domain/payment-terms/payment-term.aggregate";
|
||||
import { PaymentTermDueDays } from "../../../../../domain/payment-terms/payment-term-due-days";
|
||||
import { PaymentTermPercentage } from "../../../../../domain/payment-terms/payment-term-percentage";
|
||||
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models";
|
||||
import { PaymentTerm } from "../../../../../domain/payment-terms/payment-term.aggregate.ts";
|
||||
import { PaymentTermDueDays } from "../../../../../domain/payment-terms/payment-term-due-days.ts";
|
||||
import { PaymentTermPercentage } from "../../../../../domain/payment-terms/payment-term-percentage.ts";
|
||||
import { SequelizePaymentTermSummaryMapper } from "../mappers/index.ts";
|
||||
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models/index.ts";
|
||||
|
||||
import { SequelizePaymentTermRepository } from "./sequelize-payment-term.repository";
|
||||
import { SequelizePaymentTermRepository } from "./sequelize-payment-term.repository.ts";
|
||||
|
||||
const createRuleProps = (params: { dueDays: number; percentageValue: number }) => {
|
||||
const dueDaysResult = PaymentTermDueDays.create(params.dueDays);
|
||||
@ -50,9 +53,17 @@ const createPaymentTerm = (companyId = UniqueID.generateNewID()) => {
|
||||
return paymentTermResult.data;
|
||||
};
|
||||
|
||||
const createUniqueId = (value: string) => {
|
||||
const result = UniqueID.create(value);
|
||||
assert.equal(result.isSuccess, true);
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const createRepository = (params?: {
|
||||
mapToPersistence?: () => Result<any, Error>;
|
||||
mapToDomain?: () => Result<PaymentTerm, Error>;
|
||||
summaryMapper?: SequelizePaymentTermSummaryMapper;
|
||||
database?: { escape: (value: string) => string };
|
||||
}) =>
|
||||
new SequelizePaymentTermRepository(
|
||||
{
|
||||
@ -78,10 +89,39 @@ const createRepository = (params?: {
|
||||
})),
|
||||
mapToDomain: params?.mapToDomain ?? (() => Result.ok(createPaymentTerm())),
|
||||
} as any,
|
||||
{} as any,
|
||||
{ models: {} } as any
|
||||
(params?.summaryMapper ?? ({} as any)) as any,
|
||||
{ models: {}, escape: (value: string) => `'${value}'`, ...params?.database } as any as any
|
||||
);
|
||||
|
||||
const createSummaryRow = (params?: {
|
||||
id?: string;
|
||||
companyId?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
isActive?: boolean;
|
||||
isSystem?: boolean;
|
||||
dueRules?: Array<{ dueDays: number; percentageValue: number }>;
|
||||
}) => ({
|
||||
id: params?.id ?? "11111111-1111-4111-8111-111111111111",
|
||||
company_id: params?.companyId ?? "22222222-2222-4222-8222-222222222222",
|
||||
name: params?.name ?? "Contado 30/60",
|
||||
description: params?.description ?? "Pago en dos cuotas",
|
||||
is_active: params?.isActive ?? true,
|
||||
is_system: params?.isSystem ?? false,
|
||||
due_rules: (
|
||||
params?.dueRules ?? [
|
||||
{ dueDays: 30, percentageValue: 5000 },
|
||||
{ dueDays: 60, percentageValue: 5000 },
|
||||
]
|
||||
).map((rule, index) => ({
|
||||
due_id: `33333333-3333-4333-8333-33333333333${index}`,
|
||||
payment_term_id: params?.id ?? "11111111-1111-4111-8111-111111111111",
|
||||
due_days: rule.dueDays,
|
||||
percentage_value: rule.percentageValue,
|
||||
percentage_scale: 2,
|
||||
})),
|
||||
});
|
||||
|
||||
describe("SequelizePaymentTermRepository", () => {
|
||||
test("create persists parent and due rules atomically through nested create", async () => {
|
||||
const repository = createRepository();
|
||||
@ -394,4 +434,129 @@ describe("SequelizePaymentTermRepository", () => {
|
||||
(PaymentTermDueRuleModel as any).bulkCreate = originalBulkCreate;
|
||||
}
|
||||
});
|
||||
|
||||
test("findByCriteriaInCompany incluye description en el summary", async () => {
|
||||
const repository = createRepository({
|
||||
summaryMapper: new SequelizePaymentTermSummaryMapper(),
|
||||
});
|
||||
const originalFindAll = PaymentTermModel.findAll;
|
||||
const originalCount = PaymentTermModel.count;
|
||||
|
||||
(PaymentTermModel as any).findAll = async () => [createSummaryRow()];
|
||||
(PaymentTermModel as any).count = async () => 1;
|
||||
|
||||
try {
|
||||
const result = await repository.findByCriteriaInCompany(
|
||||
createUniqueId("22222222-2222-4222-8222-222222222222"),
|
||||
Criteria.fromPrimitives([], "name", "ASC", 10, 0)
|
||||
);
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data.total(), 1);
|
||||
|
||||
const item = result.data.getAll()[0];
|
||||
assert.ok(item);
|
||||
assert.equal(item.description, "Pago en dos cuotas");
|
||||
assert.equal(item.dueRules.length, 2);
|
||||
} finally {
|
||||
(PaymentTermModel as any).findAll = originalFindAll;
|
||||
(PaymentTermModel as any).count = originalCount;
|
||||
}
|
||||
});
|
||||
|
||||
test("findByCriteriaInCompany configura q sobre name y description", async () => {
|
||||
const repository = createRepository({
|
||||
summaryMapper: new SequelizePaymentTermSummaryMapper(),
|
||||
});
|
||||
const originalFindAll = PaymentTermModel.findAll;
|
||||
const originalCount = PaymentTermModel.count;
|
||||
let findAllOptions: Record<string, unknown> | undefined;
|
||||
let countOptions: Record<string, unknown> | undefined;
|
||||
|
||||
(PaymentTermModel as any).findAll = async (options: Record<string, unknown>) => {
|
||||
findAllOptions = options;
|
||||
return [createSummaryRow()];
|
||||
};
|
||||
(PaymentTermModel as any).count = async (options: Record<string, unknown>) => {
|
||||
countOptions = options;
|
||||
return 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await repository.findByCriteriaInCompany(
|
||||
createUniqueId("22222222-2222-4222-8222-222222222222"),
|
||||
Criteria.fromPrimitives([], "name", "ASC", 10, 0, "contado")
|
||||
);
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.ok(findAllOptions);
|
||||
assert.ok(countOptions);
|
||||
assert.deepEqual(countOptions?.where, findAllOptions?.where);
|
||||
const where = findAllOptions?.where as Record<string, unknown>;
|
||||
const andConditions = where[Op.and as unknown as keyof typeof where] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
assert.ok(Array.isArray(andConditions));
|
||||
assert.equal(andConditions.length, 2);
|
||||
assert.deepEqual(andConditions[0], {
|
||||
[Op.or]: [
|
||||
{ name: { [Op.like]: "%contado%" } },
|
||||
{ description: { [Op.like]: "%contado%" } },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(andConditions[1], {
|
||||
company_id: "22222222-2222-4222-8222-222222222222",
|
||||
deleted_at: null,
|
||||
});
|
||||
} finally {
|
||||
(PaymentTermModel as any).findAll = originalFindAll;
|
||||
(PaymentTermModel as any).count = originalCount;
|
||||
}
|
||||
});
|
||||
|
||||
test("findByCriteriaInCompany combina q + isActive y mantiene el count filtrado", async () => {
|
||||
const repository = createRepository({
|
||||
summaryMapper: new SequelizePaymentTermSummaryMapper(),
|
||||
});
|
||||
const originalFindAll = PaymentTermModel.findAll;
|
||||
const originalCount = PaymentTermModel.count;
|
||||
let findAllOptions: Record<string, unknown> | undefined;
|
||||
let countOptions: Record<string, unknown> | undefined;
|
||||
|
||||
(PaymentTermModel as any).findAll = async (options: Record<string, unknown>) => {
|
||||
findAllOptions = options;
|
||||
return [createSummaryRow({ description: "Contado inmediato", isActive: true })];
|
||||
};
|
||||
(PaymentTermModel as any).count = async (options: Record<string, unknown>) => {
|
||||
countOptions = options;
|
||||
return 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await repository.findByCriteriaInCompany(
|
||||
createUniqueId("22222222-2222-4222-8222-222222222222"),
|
||||
Criteria.fromPrimitives(
|
||||
[{ field: "isActive", operator: "EQUALS", value: "true" }],
|
||||
"name",
|
||||
"ASC",
|
||||
10,
|
||||
0,
|
||||
"contado"
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.ok(findAllOptions);
|
||||
assert.deepEqual(countOptions?.where, findAllOptions?.where);
|
||||
|
||||
const where = findAllOptions?.where as Record<string, unknown>;
|
||||
const andConditions = where[Op.and as unknown as keyof typeof where] as unknown[];
|
||||
assert.ok(Array.isArray(andConditions));
|
||||
assert.equal(andConditions.length, 3);
|
||||
} finally {
|
||||
(PaymentTermModel as any).findAll = originalFindAll;
|
||||
(PaymentTermModel as any).count = originalCount;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { Op, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type { PaymentTermSummary } from "../../../../../application/payment-terms/models/payment-term-summary.model";
|
||||
import type { IPaymentTermRepository } from "../../../../../application/payment-terms/repositories/payment-term-repository.interface";
|
||||
@ -18,6 +18,8 @@ import type {
|
||||
} from "../mappers";
|
||||
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models";
|
||||
|
||||
import { buildPaymentTermQuickSearchWhere } from "./payment-term-quick-search";
|
||||
|
||||
export class SequelizePaymentTermRepository
|
||||
extends SequelizeRepository<PaymentTerm>
|
||||
implements IPaymentTermRepository
|
||||
@ -166,15 +168,22 @@ export class SequelizePaymentTermRepository
|
||||
},
|
||||
},
|
||||
sortableFields: ["name"],
|
||||
enableFullText: true,
|
||||
database: this.database,
|
||||
strictMode: true,
|
||||
});
|
||||
|
||||
const quickSearchWhere = buildPaymentTermQuickSearchWhere({
|
||||
quickSearch: criteria.quickSearch,
|
||||
});
|
||||
|
||||
query.where = {
|
||||
...query.where,
|
||||
company_id: companyId.toString(),
|
||||
deleted_at: null,
|
||||
[Op.and]: [
|
||||
...(quickSearchWhere ? [quickSearchWhere] : []),
|
||||
...(query.where ? [query.where] : []),
|
||||
{
|
||||
company_id: companyId.toString(),
|
||||
deleted_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, count] = await Promise.all([
|
||||
|
||||
@ -7,6 +7,7 @@ export const PaymentTermSummarySchema = z.object({
|
||||
company_id: z.uuid(),
|
||||
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
is_active: z.boolean(),
|
||||
is_system: z.boolean(),
|
||||
due_rules: z.array(PaymentTermDueRuleSchema),
|
||||
|
||||
@ -128,6 +128,165 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"paymentTerms": {
|
||||
"title": "Payment terms",
|
||||
"description": "Manage the due date templates available for the company.",
|
||||
"toolbar": {
|
||||
"searchPlaceholder": "Search by name or description…",
|
||||
"statusLabel": "Status",
|
||||
"statusOptions": {
|
||||
"all": "All",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"totalCount_one": "{{count}} payment term in total",
|
||||
"totalCount_other": "{{count}} payment terms in total"
|
||||
},
|
||||
"list": {
|
||||
"rulesLabel": "Due rules"
|
||||
},
|
||||
"summary": {
|
||||
"installmentsLabel": "Installments",
|
||||
"installmentsCount_one": "{{count}} installment",
|
||||
"installmentsCount_other": "{{count}} installments",
|
||||
"lastInstallmentLabel": "Last installment at",
|
||||
"distributionLabel": "Distribution"
|
||||
},
|
||||
"details": {
|
||||
"title": "Due rules",
|
||||
"sameDay": "Cash payment",
|
||||
"baseDate": "From the base date"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"nameDescription": "Visible payment term name.",
|
||||
"namePlaceholder": "e.g. 30/60/90",
|
||||
"description": "Description",
|
||||
"descriptionDescription": "Optional internal information.",
|
||||
"descriptionPlaceholder": "Add an optional description",
|
||||
"active": "Active",
|
||||
"activeDescription": "Controls whether the payment term is available.",
|
||||
"dueDays": "Days",
|
||||
"dueDaysDescription": "Days from the document base date.",
|
||||
"dueDaysPlaceholder": "0",
|
||||
"percentage": "Percentage",
|
||||
"percentageDescription": "Exact percentage due for this rule.",
|
||||
"percentagePlaceholder": "100.00"
|
||||
},
|
||||
"dueRules": {
|
||||
"sameDay": "0 days",
|
||||
"days_one": "{{count}} day",
|
||||
"days_other": "{{count}} days"
|
||||
},
|
||||
"actions": {
|
||||
"create": "New term",
|
||||
"creating": "Creating…",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting…",
|
||||
"more": "More actions",
|
||||
"addRule": "Add due rule",
|
||||
"removeRule": "Remove due rule"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"system": "System"
|
||||
},
|
||||
"create": {
|
||||
"title": "New payment term",
|
||||
"description": "Create a new due date template for this company."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit payment term",
|
||||
"description": "Update the selected payment term information and due rules."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete payment term",
|
||||
"description": "This term will stop being available. If it is still in use, the operation will not be completed.",
|
||||
"errorTitle": "The payment term could not be deleted",
|
||||
"inUse": "This payment term cannot be deleted because it is being used."
|
||||
},
|
||||
"empty": {
|
||||
"title": "There are no payment terms configured.",
|
||||
"description": "Available payment terms will appear here."
|
||||
},
|
||||
"emptyFiltered": {
|
||||
"title": "No payment terms were found for the current criteria.",
|
||||
"description": "Try another search or clear the filter to see every payment term again.",
|
||||
"clear": "Clear search and filter"
|
||||
},
|
||||
"messages": {
|
||||
"createdTitle": "Payment term created",
|
||||
"created": "The payment term was created successfully.",
|
||||
"updatedTitle": "Payment term updated",
|
||||
"updated": "Changes were saved successfully.",
|
||||
"enabledTitle": "Payment term enabled",
|
||||
"enabled": "The term is available again.",
|
||||
"disabledTitle": "Payment term disabled",
|
||||
"disabled": "The term is no longer available.",
|
||||
"deletedTitle": "Payment term deleted",
|
||||
"deleted": "The payment term is no longer available.",
|
||||
"validationTitle": "Review the fields",
|
||||
"validationDescription": "There are validation errors in the form."
|
||||
},
|
||||
"protections": {
|
||||
"systemEdit": "System payment terms cannot be edited.",
|
||||
"systemDisable": "System payment terms cannot be disabled.",
|
||||
"systemDelete": "System payment terms cannot be deleted."
|
||||
},
|
||||
"form": {
|
||||
"rules": {
|
||||
"title": "Due rules",
|
||||
"description": "Configure the days and percentages that must total exactly 100.00%."
|
||||
},
|
||||
"total": {
|
||||
"label": "Total: {{amount}}",
|
||||
"valid": "The distribution totals exactly 100.00%.",
|
||||
"missing": "{{amount}} is missing to reach 100.00%.",
|
||||
"exceeded": "The total exceeds 100.00% by {{amount}}."
|
||||
}
|
||||
},
|
||||
"pageError": {
|
||||
"actions": {
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying…"
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "You do not have access to this setting",
|
||||
"description": "You do not have permission to view payment terms."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "The settings could not be loaded",
|
||||
"description": "The requested settings are no longer available."
|
||||
},
|
||||
"network": {
|
||||
"title": "Could not connect to the server",
|
||||
"description": "Check your connection and try again."
|
||||
},
|
||||
"timeout": {
|
||||
"title": "Loading is taking too long",
|
||||
"description": "The server did not respond in time. Try again."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Service unavailable",
|
||||
"description": "The settings are not available right now."
|
||||
},
|
||||
"server": {
|
||||
"title": "The settings could not be loaded",
|
||||
"description": "An internal error occurred while loading payment terms."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "The settings could not be loaded",
|
||||
"description": "An unexpected error occurred while loading payment terms."
|
||||
}
|
||||
}
|
||||
},
|
||||
"taxRegimes": {
|
||||
"title": "Tax regimes",
|
||||
"description": "Manage the tax regimes available for the company.",
|
||||
|
||||
@ -128,6 +128,165 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"paymentTerms": {
|
||||
"title": "Plazos de pago",
|
||||
"description": "Gestiona las plantillas de vencimientos disponibles para la empresa.",
|
||||
"toolbar": {
|
||||
"searchPlaceholder": "Buscar por nombre o descripción…",
|
||||
"statusLabel": "Estado",
|
||||
"statusOptions": {
|
||||
"all": "Todos",
|
||||
"active": "Activos",
|
||||
"inactive": "Inactivos"
|
||||
},
|
||||
"totalCount_one": "{{count}} plazo en total",
|
||||
"totalCount_other": "{{count}} plazos en total"
|
||||
},
|
||||
"list": {
|
||||
"rulesLabel": "Vencimientos"
|
||||
},
|
||||
"summary": {
|
||||
"installmentsLabel": "Vencimientos",
|
||||
"installmentsCount_one": "{{count}} cuota",
|
||||
"installmentsCount_other": "{{count}} cuotas",
|
||||
"lastInstallmentLabel": "Última cuota a",
|
||||
"distributionLabel": "Distribución"
|
||||
},
|
||||
"details": {
|
||||
"title": "Vencimientos",
|
||||
"sameDay": "Al contado",
|
||||
"baseDate": "Desde la fecha base"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"nameDescription": "Nombre visible del plazo de pago.",
|
||||
"namePlaceholder": "Ej. 30/60/90",
|
||||
"description": "Descripción",
|
||||
"descriptionDescription": "Información adicional opcional para uso interno.",
|
||||
"descriptionPlaceholder": "Añade una descripción opcional",
|
||||
"active": "Activo",
|
||||
"activeDescription": "Controla si el plazo de pago aparece como disponible.",
|
||||
"dueDays": "Días",
|
||||
"dueDaysDescription": "Días desde la fecha base del documento.",
|
||||
"dueDaysPlaceholder": "0",
|
||||
"percentage": "Porcentaje",
|
||||
"percentageDescription": "Porcentaje exacto que vence en este plazo.",
|
||||
"percentagePlaceholder": "100,00"
|
||||
},
|
||||
"dueRules": {
|
||||
"sameDay": "0 días",
|
||||
"days_one": "{{count}} día",
|
||||
"days_other": "{{count}} días"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Nuevo plazo",
|
||||
"creating": "Creando…",
|
||||
"edit": "Editar",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"cancel": "Cancelar",
|
||||
"enable": "Activar",
|
||||
"disable": "Desactivar",
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando…",
|
||||
"more": "Más acciones",
|
||||
"addRule": "Añadir vencimiento",
|
||||
"removeRule": "Eliminar vencimiento"
|
||||
},
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nuevo plazo de pago",
|
||||
"description": "Crea una nueva plantilla de vencimientos para esta empresa."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar plazo de pago",
|
||||
"description": "Actualiza la información y los vencimientos del plazo de pago seleccionado."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar plazo de pago",
|
||||
"description": "Este plazo dejará de estar disponible. Si está siendo utilizado, la operación no se completará.",
|
||||
"errorTitle": "No se pudo eliminar el plazo de pago",
|
||||
"inUse": "No se puede eliminar este plazo de pago porque está siendo utilizado."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No hay plazos de pago configurados.",
|
||||
"description": "Cuando existan plazos de pago disponibles aparecerán aquí."
|
||||
},
|
||||
"emptyFiltered": {
|
||||
"title": "No se encontraron plazos con los criterios actuales.",
|
||||
"description": "Prueba con otra búsqueda o limpia el filtro para volver a ver todos los plazos.",
|
||||
"clear": "Limpiar búsqueda y filtro"
|
||||
},
|
||||
"messages": {
|
||||
"createdTitle": "Plazo de pago creado",
|
||||
"created": "El plazo de pago se ha creado correctamente.",
|
||||
"updatedTitle": "Plazo de pago actualizado",
|
||||
"updated": "Los cambios se han guardado correctamente.",
|
||||
"enabledTitle": "Plazo de pago activado",
|
||||
"enabled": "El plazo vuelve a estar disponible.",
|
||||
"disabledTitle": "Plazo de pago desactivado",
|
||||
"disabled": "El plazo ha dejado de estar disponible.",
|
||||
"deletedTitle": "Plazo de pago eliminado",
|
||||
"deleted": "El plazo de pago ha dejado de estar disponible.",
|
||||
"validationTitle": "Revisa los campos",
|
||||
"validationDescription": "Hay errores de validación en el formulario."
|
||||
},
|
||||
"protections": {
|
||||
"systemEdit": "Los plazos del sistema no se pueden editar.",
|
||||
"systemDisable": "Los plazos del sistema no se pueden desactivar.",
|
||||
"systemDelete": "Los plazos del sistema no se pueden eliminar."
|
||||
},
|
||||
"form": {
|
||||
"rules": {
|
||||
"title": "Vencimientos",
|
||||
"description": "Configura los días y porcentajes que deben sumar exactamente el 100,00%."
|
||||
},
|
||||
"total": {
|
||||
"label": "Total: {{amount}}",
|
||||
"valid": "La distribución suma exactamente 100,00%.",
|
||||
"missing": "Falta {{amount}} para llegar al 100,00%.",
|
||||
"exceeded": "Excede {{amount}} el 100,00%."
|
||||
}
|
||||
},
|
||||
"pageError": {
|
||||
"actions": {
|
||||
"retry": "Reintentar",
|
||||
"retrying": "Reintentando…"
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "No tienes acceso a esta configuración",
|
||||
"description": "No dispones de permisos para consultar los plazos de pago."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "No se pudo cargar la configuración",
|
||||
"description": "La configuración solicitada ya no está disponible."
|
||||
},
|
||||
"network": {
|
||||
"title": "No se pudo conectar con el servidor",
|
||||
"description": "Comprueba tu conexión e inténtalo de nuevo."
|
||||
},
|
||||
"timeout": {
|
||||
"title": "La carga está tardando demasiado",
|
||||
"description": "El servidor no respondió a tiempo. Inténtalo de nuevo."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Servicio no disponible",
|
||||
"description": "La configuración no está disponible en este momento."
|
||||
},
|
||||
"server": {
|
||||
"title": "No se pudo cargar la configuración",
|
||||
"description": "Se produjo un error interno al obtener los plazos de pago."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "No se pudo cargar la configuración",
|
||||
"description": "Se produjo un error inesperado al obtener los plazos de pago."
|
||||
}
|
||||
}
|
||||
},
|
||||
"taxRegimes": {
|
||||
"title": "Regímenes fiscales",
|
||||
"description": "Gestiona los regímenes fiscales disponibles para la empresa.",
|
||||
|
||||
@ -8,6 +8,12 @@ const PaymentMethodsSettingsPage = lazy(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const PaymentTermsSettingsPage = lazy(() =>
|
||||
import("./settings/payment-terms").then((module) => ({
|
||||
default: module.PaymentTermsSettingsPage,
|
||||
}))
|
||||
);
|
||||
|
||||
const TaxRegimesSettingsPage = lazy(() =>
|
||||
import("./settings/tax-regimes").then((module) => ({
|
||||
default: module.TaxRegimesSettingsPage,
|
||||
@ -24,6 +30,14 @@ export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[]
|
||||
},
|
||||
element: <PaymentMethodsSettingsPage />,
|
||||
},
|
||||
{
|
||||
path: "settings/general/payment-terms",
|
||||
handle: {
|
||||
layout: "app-sidebar",
|
||||
protected: true,
|
||||
},
|
||||
element: <PaymentTermsSettingsPage />,
|
||||
},
|
||||
{
|
||||
path: "settings/general/tax-regimes",
|
||||
handle: {
|
||||
|
||||
@ -12,6 +12,12 @@ test("publica la entry de Settings para payment methods", () => {
|
||||
labelKey: "settings.paymentMethods.title",
|
||||
path: "/settings/general/payment-methods",
|
||||
},
|
||||
{
|
||||
id: "payment-terms",
|
||||
sectionId: "general",
|
||||
labelKey: "settings.paymentTerms.title",
|
||||
path: "/settings/general/payment-terms",
|
||||
},
|
||||
{
|
||||
id: "tax-regimes",
|
||||
sectionId: "general",
|
||||
@ -31,6 +37,15 @@ test("expone una ruta modular para settings/general/payment-methods", () => {
|
||||
assert.equal(settingsRoute?.handle?.protected, true);
|
||||
});
|
||||
|
||||
test("expone una ruta modular para settings/general/payment-terms", () => {
|
||||
const routes = CatalogsModuleManifest.routes?.({});
|
||||
const settingsRoute = routes?.find((route) => route.path === "settings/general/payment-terms");
|
||||
|
||||
assert.ok(settingsRoute);
|
||||
assert.equal(settingsRoute?.handle?.layout, "app-sidebar");
|
||||
assert.equal(settingsRoute?.handle?.protected, true);
|
||||
});
|
||||
|
||||
test("expone una ruta modular para settings/general/tax-regimes", () => {
|
||||
const routes = CatalogsModuleManifest.routes?.({});
|
||||
const settingsRoute = routes?.find((route) => route.path === "settings/general/tax-regimes");
|
||||
|
||||
@ -22,6 +22,12 @@ export const CatalogsModuleManifest: IModuleClient = {
|
||||
labelKey: "settings.paymentMethods.title",
|
||||
path: "/settings/general/payment-methods",
|
||||
},
|
||||
{
|
||||
id: "payment-terms",
|
||||
sectionId: "general",
|
||||
labelKey: "settings.paymentTerms.title",
|
||||
path: "/settings/general/payment-terms",
|
||||
},
|
||||
{
|
||||
id: "tax-regimes",
|
||||
sectionId: "general",
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import type { GetPaymentTermByIdResponseDTO } from "../../../../common";
|
||||
import type { PaymentTerm } from "../entities";
|
||||
|
||||
const percentageDtoToNumber = (dto: { value: string; scale: string }): number => {
|
||||
return Number(dto.value) / 10 ** Number(dto.scale);
|
||||
};
|
||||
|
||||
export const GetPaymentTermByIdAdapter = {
|
||||
fromDto(dto: GetPaymentTermByIdResponseDTO): PaymentTerm {
|
||||
return {
|
||||
id: dto.id,
|
||||
companyId: dto.company_id,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
isActive: dto.is_active,
|
||||
isSystem: dto.is_system,
|
||||
dueRules: dto.due_rules.map((rule) => ({
|
||||
dueDays: Number(rule.due_days),
|
||||
percentage: percentageDtoToNumber(rule.percentage),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from "./list-payment-terms.adapter";
|
||||
export * from "./get-payment-term-by-id.adapter";
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ListPaymentTermsResponseDTO } from "../../../../common";
|
||||
|
||||
import { ListPaymentTermsAdapter } from "./list-payment-terms.adapter.ts";
|
||||
|
||||
test("adapta el listado incluyendo description y reglas numéricas", () => {
|
||||
const dto: ListPaymentTermsResponseDTO = {
|
||||
page: 0,
|
||||
per_page: 10,
|
||||
total_pages: 1,
|
||||
total_items: 1,
|
||||
metadata: {
|
||||
entity: "payment_terms",
|
||||
criteria: {
|
||||
q: "",
|
||||
filters: [],
|
||||
orderBy: "name",
|
||||
orderType: "ASC",
|
||||
pageSize: 10,
|
||||
pageNumber: 0,
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
company_id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "30/60",
|
||||
description: "Pago dividido en dos cuotas",
|
||||
is_active: true,
|
||||
is_system: false,
|
||||
due_rules: [
|
||||
{
|
||||
due_days: "30",
|
||||
percentage: {
|
||||
value: "5000",
|
||||
scale: "2",
|
||||
},
|
||||
},
|
||||
{
|
||||
due_days: "60",
|
||||
percentage: {
|
||||
value: "5000",
|
||||
scale: "2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ListPaymentTermsAdapter.fromDto(dto);
|
||||
|
||||
assert.deepEqual(result.items[0], {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
name: "30/60",
|
||||
description: "Pago dividido en dos cuotas",
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
dueRules: [
|
||||
{
|
||||
dueDays: 30,
|
||||
percentage: 50,
|
||||
},
|
||||
{
|
||||
dueDays: 60,
|
||||
percentage: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ListPaymentTermsResponseDTO } from "../../../../common";
|
||||
import type { ListPaymentTermsResult } from "../api";
|
||||
import type { PaymentTermList, PaymentTermListRow } from "../entities";
|
||||
import type { PaymentTermDueRule, PaymentTermList, PaymentTermListRow } from "../entities";
|
||||
|
||||
/**
|
||||
* Adaptador para transformar los datos de la API de ListPaymentTermsResult
|
||||
@ -15,13 +15,13 @@ import type { PaymentTermList, PaymentTermListRow } from "../entities";
|
||||
*/
|
||||
|
||||
export const ListPaymentTermsAdapter = {
|
||||
fromDto(dto: ListPaymentTermsResult, _context?: unknown): PaymentTermList {
|
||||
fromDto(dto: ListPaymentTermsResult): PaymentTermList {
|
||||
return {
|
||||
page: dto.page,
|
||||
perPage: dto.per_page,
|
||||
totalPages: dto.total_pages,
|
||||
totalItems: dto.total_items,
|
||||
items: dto.items.map((rowDto) => PaymentTermListRowAdapter.fromDto(rowDto, _context)),
|
||||
items: dto.items.map((rowDto) => PaymentTermListRowAdapter.fromDto(rowDto)),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -39,22 +39,21 @@ export const ListPaymentTermsAdapter = {
|
||||
type ListPaymentTermsItemOutput = ListPaymentTermsResponseDTO["items"][number];
|
||||
|
||||
const PaymentTermListRowAdapter = {
|
||||
fromDto(dto: ListPaymentTermsItemOutput, _context?: unknown): PaymentTermListRow {
|
||||
fromDto(dto: ListPaymentTermsItemOutput): PaymentTermListRow {
|
||||
return {
|
||||
id: dto.id,
|
||||
companyId: dto.company_id,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
isSystem: dto.is_system,
|
||||
isActive: dto.is_active,
|
||||
//dueRules: dto.due_rules.map(PaymentTermDueRuleAdapter.fromDto),
|
||||
dueRules: dto.due_rules.map(PaymentTermDueRuleAdapter.fromDto),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** */
|
||||
/*
|
||||
const PaymentTermDueRuleAdapter = {
|
||||
fromDto(dto: PaymentTermDueRuleDTO): PaymentTermDueRule {
|
||||
fromDto(dto: ListPaymentTermsItemOutput["due_rules"][number]): PaymentTermDueRule {
|
||||
return {
|
||||
dueDays: Number(dto.due_days),
|
||||
percentage: percentageDtoToNumber(dto.percentage),
|
||||
@ -65,4 +64,3 @@ const PaymentTermDueRuleAdapter = {
|
||||
const percentageDtoToNumber = (dto: { value: string; scale: string }): number => {
|
||||
return Number(dto.value) / 10 ** Number(dto.scale);
|
||||
};
|
||||
*/
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { CreatePaymentTermRequestDTO, CreatePaymentTermResponseDTO } from "../../../../common";
|
||||
|
||||
export interface CreatePaymentTermParams {
|
||||
id: string;
|
||||
data: CreatePaymentTermRequestDTO;
|
||||
}
|
||||
|
||||
export function createPaymentTerm(
|
||||
dataSource: IDataSource,
|
||||
params: CreatePaymentTermParams
|
||||
): Promise<CreatePaymentTermResponseDTO> {
|
||||
return dataSource.createOne<CreatePaymentTermRequestDTO, CreatePaymentTermResponseDTO>(
|
||||
"catalogs/payment-terms",
|
||||
params.data
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
export interface DeletePaymentTermParams {
|
||||
paymentTermId: string;
|
||||
}
|
||||
|
||||
export function deletePaymentTerm(dataSource: IDataSource, params: DeletePaymentTermParams) {
|
||||
return dataSource.deleteOne("catalogs/payment-terms", params.paymentTermId);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { UpdatePaymentTermByIdResponseDTO } from "../../../../common";
|
||||
|
||||
export interface DisablePaymentTermParams {
|
||||
paymentTermId: string;
|
||||
}
|
||||
|
||||
export function disablePaymentTerm(
|
||||
dataSource: IDataSource,
|
||||
params: DisablePaymentTermParams
|
||||
): Promise<UpdatePaymentTermByIdResponseDTO> {
|
||||
return dataSource.custom<undefined, UpdatePaymentTermByIdResponseDTO>({
|
||||
method: "patch",
|
||||
path: `catalogs/payment-terms/${params.paymentTermId}/disable`,
|
||||
data: undefined,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { UpdatePaymentTermByIdResponseDTO } from "../../../../common";
|
||||
|
||||
export interface EnablePaymentTermParams {
|
||||
paymentTermId: string;
|
||||
}
|
||||
|
||||
export function enablePaymentTerm(
|
||||
dataSource: IDataSource,
|
||||
params: EnablePaymentTermParams
|
||||
): Promise<UpdatePaymentTermByIdResponseDTO> {
|
||||
return dataSource.custom<undefined, UpdatePaymentTermByIdResponseDTO>({
|
||||
method: "patch",
|
||||
path: `catalogs/payment-terms/${params.paymentTermId}/enable`,
|
||||
data: undefined,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { GetPaymentTermByIdResponseDTO } from "../../../../common";
|
||||
|
||||
export interface GetPaymentTermByIdParams {
|
||||
paymentTermId: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export function getPaymentTermById(
|
||||
dataSource: IDataSource,
|
||||
params: GetPaymentTermByIdParams
|
||||
): Promise<GetPaymentTermByIdResponseDTO> {
|
||||
return dataSource.getOne<GetPaymentTermByIdResponseDTO>(
|
||||
"catalogs/payment-terms",
|
||||
params.paymentTermId,
|
||||
{
|
||||
signal: params.signal,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -1 +1,7 @@
|
||||
export * from "./list-payment-terms-by-criteria.api";
|
||||
export * from "./get-payment-term-by-id.api";
|
||||
export * from "./create-payment-term.api";
|
||||
export * from "./update-payment-term.api";
|
||||
export * from "./delete-payment-term.api";
|
||||
export * from "./enable-payment-term.api";
|
||||
export * from "./disable-payment-term.api";
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { UpdatePaymentTermByIdRequestDTO, UpdatePaymentTermByIdResponseDTO } from "../../../../common";
|
||||
|
||||
export interface UpdatePaymentTermParams {
|
||||
paymentTermId: string;
|
||||
data: UpdatePaymentTermByIdRequestDTO;
|
||||
}
|
||||
|
||||
export function updatePaymentTerm(
|
||||
dataSource: IDataSource,
|
||||
params: UpdatePaymentTermParams
|
||||
): Promise<UpdatePaymentTermByIdResponseDTO> {
|
||||
return dataSource.updateOne<UpdatePaymentTermByIdRequestDTO, UpdatePaymentTermByIdResponseDTO>(
|
||||
"catalogs/payment-terms",
|
||||
params.paymentTermId,
|
||||
params.data
|
||||
);
|
||||
}
|
||||
@ -5,12 +5,16 @@
|
||||
* la información básica de cada vencimiento en la lista.
|
||||
*/
|
||||
|
||||
import type { PaymentTermDueRule } from "./payment-term.entity";
|
||||
|
||||
export interface PaymentTermListRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
name: string;
|
||||
description: string | null;
|
||||
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
dueRules: PaymentTermDueRule[];
|
||||
}
|
||||
|
||||
@ -1 +1,7 @@
|
||||
export * from "./use-payment-term-list-query";
|
||||
export * from "./use-payment-term-detail-query";
|
||||
export * from "./use-payment-term-create-mutation";
|
||||
export * from "./use-payment-term-update-mutation";
|
||||
export * from "./use-payment-term-delete-mutation";
|
||||
export * from "./use-payment-term-enable-mutation";
|
||||
export * from "./use-payment-term-disable-mutation";
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import type { ListPaymentTermsRequestDTO } from "../../../../common";
|
||||
@ -14,8 +15,8 @@ export const LIST_PAYMENT_TERMS_QUERY_KEY = (criteria?: ListPaymentTermsRequestD
|
||||
[
|
||||
...LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
{
|
||||
pageNumber: criteria?.pageNumber ?? 1,
|
||||
pageSize: criteria?.pageSize ?? 5,
|
||||
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||
q: criteria?.q ?? "",
|
||||
filters: criteria?.filters ?? [],
|
||||
orderBy: criteria?.orderBy ?? "",
|
||||
@ -38,7 +39,11 @@ export const PAYMENT_TERM_QUERY_KEY = (paymentTermId?: string): QueryKey => [
|
||||
export const CREATE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:create"] as const;
|
||||
export const UPDATE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:update"] as const;
|
||||
export const DELETE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:delete"] as const;
|
||||
export const ENABLE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:enable"] as const;
|
||||
export const DISABLE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:disable"] as const;
|
||||
|
||||
/**
|
||||
* Operaciones de dominio
|
||||
*/
|
||||
export const paymentTermKeys = {
|
||||
all: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
list: (criteria?: ListPaymentTermsRequestDTO) => LIST_PAYMENT_TERMS_QUERY_KEY(criteria),
|
||||
detail: (paymentTermId?: string) => PAYMENT_TERM_QUERY_KEY(paymentTermId),
|
||||
};
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import type { ValidationErrorDetail } from "@repo/rdx-ddd";
|
||||
import type { ZodError } from "zod";
|
||||
|
||||
export const toValidationErrors = (error: ZodError<unknown>): ValidationErrorDetail[] =>
|
||||
error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
value: issue.input,
|
||||
}));
|
||||
@ -0,0 +1,44 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { CreatePaymentTermRequestSchema } from "../../../../common";
|
||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||
import { createPaymentTerm, type CreatePaymentTermParams } from "../api";
|
||||
import type { PaymentTerm } from "../entities";
|
||||
|
||||
import {
|
||||
CREATE_PAYMENT_TERM_MUTATION_KEY,
|
||||
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
PAYMENT_TERM_QUERY_KEY,
|
||||
} from "./keys";
|
||||
import { toValidationErrors } from "./to-validation-errors";
|
||||
|
||||
export const usePaymentTermCreateMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation<PaymentTerm, DefaultError, CreatePaymentTermParams>({
|
||||
mutationKey: CREATE_PAYMENT_TERM_MUTATION_KEY,
|
||||
mutationFn: async (params) => {
|
||||
if (!params.id) {
|
||||
throw new Error("paymentTermId is required");
|
||||
}
|
||||
|
||||
const result = CreatePaymentTermRequestSchema.safeParse(params.data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
|
||||
}
|
||||
|
||||
const dto = await createPaymentTerm(dataSource, params);
|
||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||
},
|
||||
onSuccess: async (paymentTerm) => {
|
||||
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { deletePaymentTerm, type DeletePaymentTermParams } from "../api";
|
||||
|
||||
import {
|
||||
DELETE_PAYMENT_TERM_MUTATION_KEY,
|
||||
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX,
|
||||
} from "./keys";
|
||||
|
||||
export const usePaymentTermDeleteMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation<void, DefaultError, DeletePaymentTermParams>({
|
||||
mutationKey: DELETE_PAYMENT_TERM_MUTATION_KEY,
|
||||
mutationFn: async (params) => {
|
||||
await deletePaymentTerm(dataSource, params);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }),
|
||||
queryClient.invalidateQueries({ queryKey: PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||
import { getPaymentTermById } from "../api";
|
||||
import type { PaymentTerm } from "../entities";
|
||||
|
||||
import { PAYMENT_TERM_QUERY_KEY } from "./keys";
|
||||
|
||||
export const usePaymentTermDetailQuery = (
|
||||
paymentTermId?: string,
|
||||
options?: { enabled?: boolean }
|
||||
): UseQueryResult<PaymentTerm, DefaultError> => {
|
||||
const dataSource = useDataSource();
|
||||
const enabled = (options?.enabled ?? true) && Boolean(paymentTermId);
|
||||
|
||||
return useQuery<PaymentTerm, DefaultError>({
|
||||
queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId),
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getPaymentTermById(dataSource, {
|
||||
paymentTermId: paymentTermId as string,
|
||||
signal,
|
||||
});
|
||||
|
||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||
import { disablePaymentTerm, type DisablePaymentTermParams } from "../api";
|
||||
import type { PaymentTerm } from "../entities";
|
||||
|
||||
import {
|
||||
DISABLE_PAYMENT_TERM_MUTATION_KEY,
|
||||
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
PAYMENT_TERM_QUERY_KEY,
|
||||
} from "./keys";
|
||||
|
||||
export const usePaymentTermDisableMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation<PaymentTerm, DefaultError, DisablePaymentTermParams>({
|
||||
mutationKey: DISABLE_PAYMENT_TERM_MUTATION_KEY,
|
||||
mutationFn: async (params) => {
|
||||
const dto = await disablePaymentTerm(dataSource, params);
|
||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||
},
|
||||
onSuccess: async (paymentTerm) => {
|
||||
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
|
||||
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||
import { enablePaymentTerm, type EnablePaymentTermParams } from "../api";
|
||||
import type { PaymentTerm } from "../entities";
|
||||
|
||||
import {
|
||||
ENABLE_PAYMENT_TERM_MUTATION_KEY,
|
||||
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
PAYMENT_TERM_QUERY_KEY,
|
||||
} from "./keys";
|
||||
|
||||
export const usePaymentTermEnableMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation<PaymentTerm, DefaultError, EnablePaymentTermParams>({
|
||||
mutationKey: ENABLE_PAYMENT_TERM_MUTATION_KEY,
|
||||
mutationFn: async (params) => {
|
||||
const dto = await enablePaymentTerm(dataSource, params);
|
||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||
},
|
||||
onSuccess: async (paymentTerm) => {
|
||||
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
|
||||
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -13,7 +13,7 @@ export interface PaymentTermsListQueryOptions {
|
||||
criteria?: Partial<CriteriaDTO>;
|
||||
}
|
||||
|
||||
export const usePaymentTermsListQuery = (
|
||||
export const usePaymentTermListQuery = (
|
||||
options?: PaymentTermsListQueryOptions
|
||||
): UseQueryResult<PaymentTermList, DefaultError> => {
|
||||
const dataSource = useDataSource();
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { UpdatePaymentTermByIdRequestSchema } from "../../../../common";
|
||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||
import { updatePaymentTerm, type UpdatePaymentTermParams } from "../api";
|
||||
import type { PaymentTerm } from "../entities";
|
||||
|
||||
import {
|
||||
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
PAYMENT_TERM_QUERY_KEY,
|
||||
UPDATE_PAYMENT_TERM_MUTATION_KEY,
|
||||
} from "./keys";
|
||||
import { toValidationErrors } from "./to-validation-errors";
|
||||
|
||||
export const usePaymentTermUpdateMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation<PaymentTerm, DefaultError, UpdatePaymentTermParams>({
|
||||
mutationKey: UPDATE_PAYMENT_TERM_MUTATION_KEY,
|
||||
mutationFn: async (params) => {
|
||||
if (!params.paymentTermId) {
|
||||
throw new Error("paymentTermId is required");
|
||||
}
|
||||
|
||||
const result = UpdatePaymentTermByIdRequestSchema.safeParse(params.data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
|
||||
}
|
||||
|
||||
const dto = await updatePaymentTerm(dataSource, params);
|
||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||
},
|
||||
onSuccess: async (paymentTerm) => {
|
||||
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./entities";
|
||||
export * from "./hooks";
|
||||
export * from "./api";
|
||||
export * from "./adapters";
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./payment-methods";
|
||||
export * from "./payment-terms";
|
||||
export * from "./tax-regimes";
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-payment-terms-settings-controller";
|
||||
export * from "./use-payment-terms-settings-page-controller";
|
||||
@ -0,0 +1,301 @@
|
||||
import { applyValidationErrorCollection } from "@erp/core";
|
||||
import { getUserErrorMessage, isApiClientError } from "@erp/core/client";
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import { UniqueID, type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import {
|
||||
focusFirstInputFormError,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showWarningToast,
|
||||
} from "@repo/rdx-ui/helpers";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { FieldErrors } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import {
|
||||
type PaymentTermListRow,
|
||||
usePaymentTermCreateMutation,
|
||||
usePaymentTermDeleteMutation,
|
||||
usePaymentTermDetailQuery,
|
||||
usePaymentTermDisableMutation,
|
||||
usePaymentTermEnableMutation,
|
||||
usePaymentTermUpdateMutation,
|
||||
} from "../../../payment-terms/shared";
|
||||
import type { PaymentTermForm } from "../entities";
|
||||
import { PaymentTermFormSchema } from "../entities";
|
||||
import {
|
||||
buildCreatePaymentTermParams,
|
||||
buildPaymentTermFormDefault,
|
||||
buildPaymentTermFormFromEntity,
|
||||
buildUpdatePaymentTermParams,
|
||||
} from "../utils";
|
||||
|
||||
const normalizeFormSubmitError = (error: unknown): Error | ValidationErrorCollection => {
|
||||
if (isValidationErrorCollection(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return new Error("Unknown error");
|
||||
};
|
||||
|
||||
export const usePaymentTermsSettingsController = () => {
|
||||
const { t } = useTranslation();
|
||||
const formId = useId();
|
||||
const createMutation = usePaymentTermCreateMutation();
|
||||
const updateMutation = usePaymentTermUpdateMutation();
|
||||
const deleteMutation = usePaymentTermDeleteMutation();
|
||||
const enableMutation = usePaymentTermEnableMutation();
|
||||
const disableMutation = usePaymentTermDisableMutation();
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [editingPaymentTermId, setEditingPaymentTermId] = useState<string | null>(null);
|
||||
const [deletingPaymentTerm, setDeletingPaymentTerm] = useState<PaymentTermListRow | null>(null);
|
||||
|
||||
const editingPaymentTermQuery = usePaymentTermDetailQuery(editingPaymentTermId ?? undefined, {
|
||||
enabled: editingPaymentTermId !== null,
|
||||
});
|
||||
const editingPaymentTerm = editingPaymentTermQuery.data ?? null;
|
||||
|
||||
const form = useHookForm<PaymentTermForm>({
|
||||
resolverSchema: PaymentTermFormSchema,
|
||||
initialValues: buildPaymentTermFormDefault(),
|
||||
disabled:
|
||||
createMutation.isPending || updateMutation.isPending || editingPaymentTermQuery.isLoading,
|
||||
});
|
||||
|
||||
const isSaving =
|
||||
createMutation.isPending || updateMutation.isPending || editingPaymentTermQuery.isLoading;
|
||||
const isTogglingActive = enableMutation.isPending || disableMutation.isPending;
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
form.reset(buildPaymentTermFormDefault(), { keepDirty: false });
|
||||
form.clearErrors();
|
||||
}, [form]);
|
||||
|
||||
const closeCreateDialog = useCallback(() => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreateOpen(false);
|
||||
resetForm();
|
||||
}, [isSaving, resetForm]);
|
||||
|
||||
const openCreateDialog = useCallback(() => {
|
||||
setEditingPaymentTermId(null);
|
||||
resetForm();
|
||||
setIsCreateOpen(true);
|
||||
}, [resetForm]);
|
||||
|
||||
const openEditDialog = useCallback(
|
||||
(paymentTerm: PaymentTermListRow) => {
|
||||
if (paymentTerm.isSystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreateOpen(false);
|
||||
setEditingPaymentTermId(paymentTerm.id);
|
||||
resetForm();
|
||||
},
|
||||
[resetForm]
|
||||
);
|
||||
|
||||
const closeEditDialog = useCallback(() => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingPaymentTermId(null);
|
||||
resetForm();
|
||||
}, [isSaving, resetForm]);
|
||||
|
||||
const openDeleteDialog = useCallback((paymentTerm: PaymentTermListRow) => {
|
||||
setDeletingPaymentTerm(paymentTerm);
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
if (deleteMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingPaymentTerm(null);
|
||||
}, [deleteMutation.isPending]);
|
||||
|
||||
const notifyConflictDelete = useCallback(() => {
|
||||
showErrorToast(
|
||||
t("settings.paymentTerms.delete.errorTitle"),
|
||||
t("settings.paymentTerms.delete.inUse")
|
||||
);
|
||||
}, [t]);
|
||||
|
||||
const onInvalidSubmit = useCallback(
|
||||
(errors: FieldErrors<PaymentTermForm>) => {
|
||||
focusFirstInputFormError(errors, form);
|
||||
showWarningToast(
|
||||
t("settings.paymentTerms.messages.validationTitle"),
|
||||
t("settings.paymentTerms.messages.validationDescription")
|
||||
);
|
||||
},
|
||||
[form, t]
|
||||
);
|
||||
|
||||
const submitPaymentTerm = form.handleSubmit(
|
||||
async (values) => {
|
||||
try {
|
||||
if (editingPaymentTerm) {
|
||||
await updateMutation.mutateAsync(
|
||||
buildUpdatePaymentTermParams(editingPaymentTerm.id, values)
|
||||
);
|
||||
|
||||
showSuccessToast(
|
||||
t("settings.paymentTerms.messages.updatedTitle"),
|
||||
t("settings.paymentTerms.messages.updated")
|
||||
);
|
||||
|
||||
closeEditDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = UniqueID.generateNewID().toString();
|
||||
await createMutation.mutateAsync(buildCreatePaymentTermParams(id, values));
|
||||
|
||||
showSuccessToast(
|
||||
t("settings.paymentTerms.messages.createdTitle"),
|
||||
t("settings.paymentTerms.messages.created")
|
||||
);
|
||||
|
||||
closeCreateDialog();
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = normalizeFormSubmitError(error);
|
||||
|
||||
if (isValidationErrorCollection(normalizedError)) {
|
||||
applyValidationErrorCollection(form, normalizedError);
|
||||
focusFirstInputFormError(form.formState.errors, form);
|
||||
showWarningToast(
|
||||
t("settings.paymentTerms.messages.validationTitle"),
|
||||
t("settings.paymentTerms.messages.validationDescription")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = getUserErrorMessage(normalizedError);
|
||||
showErrorToast(userMessage.title, userMessage.description);
|
||||
}
|
||||
},
|
||||
onInvalidSubmit
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
submitPaymentTerm(event).catch((error: unknown) => {
|
||||
const userMessage = getUserErrorMessage(error);
|
||||
showErrorToast(userMessage.title, userMessage.description);
|
||||
});
|
||||
},
|
||||
[submitPaymentTerm]
|
||||
);
|
||||
|
||||
const enable = useCallback(
|
||||
(paymentTerm: PaymentTermListRow) => {
|
||||
if (isTogglingActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
enableMutation
|
||||
.mutateAsync({ paymentTermId: paymentTerm.id })
|
||||
.then(() => {
|
||||
showSuccessToast(
|
||||
t("settings.paymentTerms.messages.enabledTitle"),
|
||||
t("settings.paymentTerms.messages.enabled")
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const userMessage = getUserErrorMessage(error);
|
||||
showErrorToast(userMessage.title, userMessage.description);
|
||||
});
|
||||
},
|
||||
[enableMutation, isTogglingActive, t]
|
||||
);
|
||||
|
||||
const disable = useCallback(
|
||||
(paymentTerm: PaymentTermListRow) => {
|
||||
if (isTogglingActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
disableMutation
|
||||
.mutateAsync({ paymentTermId: paymentTerm.id })
|
||||
.then(() => {
|
||||
showSuccessToast(
|
||||
t("settings.paymentTerms.messages.disabledTitle"),
|
||||
t("settings.paymentTerms.messages.disabled")
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const userMessage = getUserErrorMessage(error);
|
||||
showErrorToast(userMessage.title, userMessage.description);
|
||||
});
|
||||
},
|
||||
[disableMutation, isTogglingActive, t]
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deletingPaymentTerm || deleteMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteMutation
|
||||
.mutateAsync({ paymentTermId: deletingPaymentTerm.id })
|
||||
.then(() => {
|
||||
showSuccessToast(
|
||||
t("settings.paymentTerms.messages.deletedTitle"),
|
||||
t("settings.paymentTerms.messages.deleted")
|
||||
);
|
||||
closeDeleteDialog();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isApiClientError(error) && error.kind === "conflict") {
|
||||
notifyConflictDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = getUserErrorMessage(error);
|
||||
showErrorToast(userMessage.title, userMessage.description);
|
||||
});
|
||||
}, [closeDeleteDialog, deleteMutation, deletingPaymentTerm, notifyConflictDelete, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingPaymentTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(buildPaymentTermFormFromEntity(editingPaymentTerm), { keepDirty: false });
|
||||
}, [editingPaymentTerm, form]);
|
||||
|
||||
return {
|
||||
form,
|
||||
formId,
|
||||
isCreateOpen,
|
||||
editingPaymentTerm,
|
||||
isEditingPaymentTermLoading: editingPaymentTermQuery.isLoading,
|
||||
deletingPaymentTerm,
|
||||
isSaving,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isTogglingActive,
|
||||
openCreateDialog,
|
||||
closeCreateDialog,
|
||||
openEditDialog,
|
||||
closeEditDialog,
|
||||
openDeleteDialog,
|
||||
closeDeleteDialog,
|
||||
onSubmit,
|
||||
enable,
|
||||
disable,
|
||||
confirmDelete,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { usePaymentTermListQuery } from "../../../payment-terms/shared";
|
||||
import {
|
||||
type PaymentTermsStatusFilter,
|
||||
buildPaymentTermsListCriteria,
|
||||
buildPaymentTermsSettingsPageErrorViewModel,
|
||||
hasPaymentTermsListCriteriaApplied,
|
||||
resolvePaymentTermsSettingsFeedbackState,
|
||||
} from "../utils";
|
||||
|
||||
import { usePaymentTermsSettingsController } from "./use-payment-terms-settings-controller";
|
||||
|
||||
export const usePaymentTermsSettingsPageController = () => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<PaymentTermsStatusFilter>("all");
|
||||
const [debouncedSearch] = useDebounce(search, 300);
|
||||
const criteria = useMemo(
|
||||
() =>
|
||||
buildPaymentTermsListCriteria({
|
||||
search: debouncedSearch,
|
||||
status,
|
||||
}),
|
||||
[debouncedSearch, status]
|
||||
);
|
||||
const listQuery = usePaymentTermListQuery({ criteria });
|
||||
const controller = usePaymentTermsSettingsController();
|
||||
|
||||
const feedbackState = resolvePaymentTermsSettingsFeedbackState({
|
||||
isLoading: listQuery.isLoading,
|
||||
isError: listQuery.isError,
|
||||
error: listQuery.error,
|
||||
});
|
||||
|
||||
const pageError = useMemo(() => {
|
||||
if (!feedbackState.hasBlockingError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildPaymentTermsSettingsPageErrorViewModel(listQuery.error, (key) => t(key));
|
||||
}, [feedbackState.hasBlockingError, listQuery.error, t]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
await listQuery.refetch();
|
||||
}, [listQuery]);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearch("");
|
||||
setStatus("all");
|
||||
}, []);
|
||||
|
||||
const hasAppliedCriteria = hasPaymentTermsListCriteriaApplied({
|
||||
search: debouncedSearch,
|
||||
status,
|
||||
});
|
||||
const totalItems = listQuery.data?.totalItems ?? 0;
|
||||
const list = listQuery.data?.items ?? [];
|
||||
const isBackgroundFetching = listQuery.isFetching && !listQuery.isLoading;
|
||||
const hasNoResults = !feedbackState.isInitialLoading && totalItems === 0 && hasAppliedCriteria;
|
||||
const hasEmptyState = !feedbackState.isInitialLoading && totalItems === 0 && !hasAppliedCriteria;
|
||||
|
||||
return {
|
||||
list,
|
||||
totalItems,
|
||||
isInitialLoading: feedbackState.isInitialLoading,
|
||||
isBackgroundFetching,
|
||||
hasNoResults,
|
||||
hasEmptyState,
|
||||
hasAppliedCriteria,
|
||||
search,
|
||||
setSearch,
|
||||
status,
|
||||
setStatus,
|
||||
clearFilters,
|
||||
criteria,
|
||||
pageError,
|
||||
retry,
|
||||
isRetrying: isBackgroundFetching,
|
||||
listQuery,
|
||||
controller,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./payment-term-form.entity";
|
||||
export * from "./payment-term-form.schema";
|
||||
export * from "./payment-terms-settings-page-error-view-model.entity";
|
||||
@ -0,0 +1,5 @@
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { PaymentTermFormSchema } from "./payment-term-form.schema";
|
||||
|
||||
export type PaymentTermForm = z.infer<typeof PaymentTermFormSchema>;
|
||||
@ -0,0 +1,87 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
parsePaymentTermDueDays,
|
||||
parsePaymentTermPercentageToHundredths,
|
||||
} from "../utils/payment-term-due-rules.utils";
|
||||
|
||||
const buildInvalidPercentageIssueMessage = (differenceInHundredths: number) => {
|
||||
const absoluteDifference = Math.abs(differenceInHundredths);
|
||||
const units = Math.trunc(absoluteDifference / 100);
|
||||
const decimals = String(absoluteDifference % 100).padStart(2, "0");
|
||||
const formattedDifference = `${units}.${decimals}`;
|
||||
|
||||
if (differenceInHundredths < 0) {
|
||||
return `Falta ${formattedDifference}% para llegar al 100,00%.`;
|
||||
}
|
||||
|
||||
return `Se supera el 100,00% por ${formattedDifference}%.`;
|
||||
};
|
||||
|
||||
const PaymentTermDueRuleFormSchema = z.object({
|
||||
dueDays: z.string().trim().min(1, "Los días son obligatorios."),
|
||||
percentage: z.string().trim().min(1, "El porcentaje es obligatorio."),
|
||||
});
|
||||
|
||||
export const PaymentTermFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "El nombre es obligatorio."),
|
||||
description: z.string().trim(),
|
||||
isActive: z.boolean(),
|
||||
dueRules: z
|
||||
.array(PaymentTermDueRuleFormSchema)
|
||||
.min(1, "Debes añadir al menos un vencimiento."),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seenDueDays = new Map<number, number>();
|
||||
let totalPercentage = 0;
|
||||
|
||||
value.dueRules.forEach((rule, index) => {
|
||||
const dueDays = parsePaymentTermDueDays(rule.dueDays);
|
||||
|
||||
if (dueDays === null) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Los días deben ser un entero igual o mayor que 0.",
|
||||
path: ["dueRules", index, "dueDays"],
|
||||
});
|
||||
} else {
|
||||
const previousIndex = seenDueDays.get(dueDays);
|
||||
|
||||
if (previousIndex === undefined) {
|
||||
seenDueDays.set(dueDays, index);
|
||||
} else {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Los días no pueden repetirse.",
|
||||
path: ["dueRules", index, "dueDays"],
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Los días no pueden repetirse.",
|
||||
path: ["dueRules", previousIndex, "dueDays"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = parsePaymentTermPercentageToHundredths(rule.percentage);
|
||||
|
||||
if (percentage === null) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "El porcentaje debe ser mayor que 0,00 y menor o igual que 100,00.",
|
||||
path: ["dueRules", index, "percentage"],
|
||||
});
|
||||
} else {
|
||||
totalPercentage += percentage;
|
||||
}
|
||||
});
|
||||
|
||||
if (totalPercentage !== 10000) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: buildInvalidPercentageIssueMessage(totalPercentage - 10000),
|
||||
path: ["dueRules"],
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import type { PageErrorStateProps } from "@erp/core/components";
|
||||
|
||||
export interface PaymentTermsSettingsPageErrorViewModel {
|
||||
variant: PageErrorStateProps["variant"];
|
||||
title: string;
|
||||
description: string;
|
||||
reference?: string;
|
||||
canRetry: boolean;
|
||||
}
|
||||
1
modules/catalogs/src/web/settings/payment-terms/index.ts
Normal file
1
modules/catalogs/src/web/settings/payment-terms/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./ui";
|
||||
@ -0,0 +1,62 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { PaymentTermListRow } from "../../../../payment-terms/shared";
|
||||
|
||||
interface DeletePaymentTermDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target: PaymentTermListRow | null;
|
||||
isSubmitting: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeletePaymentTermDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
target,
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
}: DeletePaymentTermDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("settings.paymentTerms.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.paymentTerms.delete.description", {
|
||||
name: target?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t("settings.paymentTerms.actions.cancel")}
|
||||
</Button>
|
||||
<Button disabled={isSubmitting || !target} onClick={onConfirm} variant="destructive">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner aria-hidden className="mr-2 size-4" />
|
||||
{t("settings.paymentTerms.actions.deleting")}
|
||||
</>
|
||||
) : (
|
||||
t("settings.paymentTerms.actions.delete")
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
export * from "./payment-term-form-dialog";
|
||||
export * from "./delete-payment-term-dialog";
|
||||
export * from "./payment-term-list-item";
|
||||
export * from "./payment-term-row-actions";
|
||||
export * from "./payment-term-segmented-bar";
|
||||
export * from "./payment-terms-empty-state";
|
||||
export * from "./payment-terms-no-results-state";
|
||||
export * from "./payment-terms-settings-skeleton";
|
||||
@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { FormEventHandler } from "react";
|
||||
import { FormProvider, useFieldArray, useWatch } from "react-hook-form";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import {
|
||||
RdxFormGrid,
|
||||
SwitchField,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { PaymentTermForm } from "../../entities";
|
||||
import {
|
||||
buildPaymentTermPercentageStatus,
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths,
|
||||
formatPaymentTermTotalPercentage,
|
||||
} from "../../utils";
|
||||
|
||||
interface PaymentTermFormDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
submitLabel: string;
|
||||
openChange: (open: boolean) => void;
|
||||
formId: string;
|
||||
form: UseFormReturn<PaymentTermForm>;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
isSubmitting: boolean;
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
const totalToneClassNames: Record<"success" | "warning" | "destructive", string> = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-900",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-900",
|
||||
destructive: "border-red-200 bg-red-50 text-red-900",
|
||||
};
|
||||
|
||||
export const PaymentTermFormDialog = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
openChange,
|
||||
formId,
|
||||
form,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
isEditMode,
|
||||
}: PaymentTermFormDialogProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "dueRules",
|
||||
});
|
||||
|
||||
const watchedDueRules = useWatch({
|
||||
control: form.control,
|
||||
name: "dueRules",
|
||||
defaultValue: form.getValues("dueRules"),
|
||||
});
|
||||
const dueRules = useMemo(() => {
|
||||
return fields.map((field, index) => {
|
||||
return watchedDueRules?.[index] ?? {
|
||||
dueDays: field.dueDays ?? "",
|
||||
percentage: field.percentage ?? "",
|
||||
};
|
||||
});
|
||||
}, [fields, watchedDueRules]);
|
||||
|
||||
const totalPercentageInHundredths = useMemo(() => {
|
||||
return calculatePaymentTermDueRulesTotalPercentageInHundredths(dueRules);
|
||||
}, [dueRules]);
|
||||
|
||||
const totalStatus = useMemo(() => {
|
||||
return buildPaymentTermPercentageStatus(totalPercentageInHundredths, t, i18n.language);
|
||||
}, [i18n.language, t, totalPercentageInHundredths]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFocus("name");
|
||||
}, [form, open]);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={openChange} open={open}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
|
||||
<RdxFormGrid className="grid-cols-1 md:grid-cols-1">
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
className="w-full"
|
||||
description={t("settings.paymentTerms.fields.nameDescription")}
|
||||
disabled={isSubmitting}
|
||||
label={t("settings.paymentTerms.fields.name")}
|
||||
name="name"
|
||||
placeholder={t("settings.paymentTerms.fields.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
className="w-full"
|
||||
description={t("settings.paymentTerms.fields.descriptionDescription")}
|
||||
disabled={isSubmitting}
|
||||
label={t("settings.paymentTerms.fields.description")}
|
||||
name="description"
|
||||
placeholder={t("settings.paymentTerms.fields.descriptionPlaceholder")}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<SwitchField
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-3"
|
||||
description={t("settings.paymentTerms.fields.activeDescription")}
|
||||
disabled={isSubmitting}
|
||||
label={t("settings.paymentTerms.fields.active")}
|
||||
name="isActive"
|
||||
/>
|
||||
</RdxFormGrid>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">{t("settings.paymentTerms.form.rules.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.paymentTerms.form.rules.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
className={`border px-3 py-1 text-xs ${totalToneClassNames[totalStatus.tone]}`}
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.paymentTerms.form.total.label", {
|
||||
amount: formatPaymentTermTotalPercentage(
|
||||
totalPercentageInHundredths,
|
||||
i18n.language
|
||||
),
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-sm ${totalToneClassNames[totalStatus.tone]}`}
|
||||
>
|
||||
<p className="font-medium">{totalStatus.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-4 px-3 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>{t("settings.paymentTerms.fields.dueDays")}</span>
|
||||
<span>{t("settings.paymentTerms.fields.percentage")}</span>
|
||||
<span className="text-right">{t("settings.paymentTerms.actions.removeRule")}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className="rounded-xl border border-border bg-muted/20 p-3"
|
||||
key={field.id}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground md:hidden">
|
||||
{t("settings.paymentTerms.fields.dueDays")}
|
||||
</span>
|
||||
<TextField<PaymentTermForm>
|
||||
aria-label={t("settings.paymentTerms.fields.dueDays")}
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
inputMode="numeric"
|
||||
name={`dueRules.${index}.dueDays`}
|
||||
placeholder={t("settings.paymentTerms.fields.dueDaysPlaceholder")}
|
||||
typePreset="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground md:hidden">
|
||||
{t("settings.paymentTerms.fields.percentage")}
|
||||
</span>
|
||||
<TextField<PaymentTermForm>
|
||||
aria-label={t("settings.paymentTerms.fields.percentage")}
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
inputClassName="tabular-nums"
|
||||
inputMode="decimal"
|
||||
name={`dueRules.${index}.percentage`}
|
||||
placeholder={t("settings.paymentTerms.fields.percentagePlaceholder")}
|
||||
rightIcon={<span className="text-muted-foreground">%</span>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end md:items-start">
|
||||
<Button
|
||||
aria-label={t("settings.paymentTerms.actions.removeRule")}
|
||||
className="shrink-0"
|
||||
disabled={isSubmitting || fields.length === 1}
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
title={t("settings.paymentTerms.actions.removeRule")}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2Icon aria-hidden className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("settings.paymentTerms.actions.removeRule")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onClick={() => append({ dueDays: "", percentage: "" })}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("settings.paymentTerms.actions.addRule")}
|
||||
</Button>
|
||||
</section>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={isSubmitting} onClick={() => openChange(false)} variant="outline">
|
||||
{t("settings.paymentTerms.actions.cancel")}
|
||||
</Button>
|
||||
<Button disabled={isSubmitting} form={formId} type="submit">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner aria-hidden className="mr-2 size-4" />
|
||||
{isEditMode
|
||||
? t("settings.paymentTerms.actions.saving")
|
||||
: t("settings.paymentTerms.actions.creating")}
|
||||
</>
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,164 @@
|
||||
import { Badge, Card, CardContent } from "@repo/shadcn-ui/components";
|
||||
import { CalendarClockIcon } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { PaymentTermListRow } from "../../../../payment-terms/shared";
|
||||
import {
|
||||
buildPaymentTermDueRuleSegments,
|
||||
formatPaymentTermDueDays,
|
||||
formatPaymentTermPercentage,
|
||||
getPaymentTermInstallmentCount,
|
||||
getPaymentTermLastDueDays,
|
||||
getPaymentTermPercentageDistribution,
|
||||
sortPaymentTermDueRules,
|
||||
} from "../../utils";
|
||||
|
||||
import { PaymentTermRowActions } from "./payment-term-row-actions";
|
||||
import { PaymentTermSegmentedBar } from "./payment-term-segmented-bar";
|
||||
|
||||
interface PaymentTermListItemProps {
|
||||
paymentTerm: PaymentTermListRow;
|
||||
onEdit: (paymentTerm: PaymentTermListRow) => void;
|
||||
onToggleActive: (paymentTerm: PaymentTermListRow) => void;
|
||||
onDelete: (paymentTerm: PaymentTermListRow) => void;
|
||||
activeDisabledReason?: string;
|
||||
deleteDisabledReason?: string;
|
||||
isTogglingActive?: boolean;
|
||||
}
|
||||
|
||||
export const PaymentTermListItem = ({
|
||||
paymentTerm,
|
||||
onEdit,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
activeDisabledReason,
|
||||
deleteDisabledReason,
|
||||
isTogglingActive = false,
|
||||
}: PaymentTermListItemProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const sortedDueRules = sortPaymentTermDueRules(paymentTerm.dueRules);
|
||||
const installmentCount = getPaymentTermInstallmentCount(sortedDueRules);
|
||||
const lastDueDays = getPaymentTermLastDueDays(sortedDueRules);
|
||||
const percentageDistribution = getPaymentTermPercentageDistribution(sortedDueRules);
|
||||
const segments = buildPaymentTermDueRuleSegments(sortedDueRules);
|
||||
|
||||
const formatLocalizedPercentage = (value: number) =>
|
||||
formatPaymentTermPercentage(value, i18n.language);
|
||||
const formatLocalizedDueDays = (value: number) => formatPaymentTermDueDays(value, t);
|
||||
|
||||
return (
|
||||
<Card className="border-border/70 shadow-none">
|
||||
<CardContent className="flex flex-col gap-5 p-4 sm:gap-6 sm:p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3 sm:gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-muted/40 text-primary sm:size-11">
|
||||
<CalendarClockIcon aria-hidden className="size-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground sm:text-base">
|
||||
{paymentTerm.name}
|
||||
</h3>
|
||||
|
||||
<Badge variant={paymentTerm.isActive ? "outline" : "secondary"}>
|
||||
{paymentTerm.isActive
|
||||
? t("settings.paymentTerms.status.active")
|
||||
: t("settings.paymentTerms.status.inactive")}
|
||||
</Badge>
|
||||
|
||||
{paymentTerm.isSystem ? (
|
||||
<Badge variant="secondary">{t("settings.paymentTerms.status.system")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{paymentTerm.description ? (
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{paymentTerm.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentTermRowActions
|
||||
activeDisabledReason={activeDisabledReason}
|
||||
deleteDisabledReason={deleteDisabledReason}
|
||||
isTogglingActive={isTogglingActive}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onToggleActive={onToggleActive}
|
||||
paymentTerm={paymentTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("settings.paymentTerms.summary.installmentsLabel")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t("settings.paymentTerms.summary.installmentsCount", { count: installmentCount })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("settings.paymentTerms.summary.lastInstallmentLabel")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{lastDueDays === null ? "-" : formatLocalizedDueDays(lastDueDays)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 px-4 py-3 md:col-span-2 xl:col-span-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("settings.paymentTerms.summary.distributionLabel")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{percentageDistribution.map(formatLocalizedPercentage).join(" / ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{t("settings.paymentTerms.details.title")}
|
||||
</h4>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{sortedDueRules.map((rule, index) => (
|
||||
<div
|
||||
className="rounded-lg border border-border/60 bg-background px-4 py-3"
|
||||
key={`${paymentTerm.id}-${rule.dueDays}-${rule.percentage}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground tabular-nums">
|
||||
{formatLocalizedPercentage(rule.percentage)} ·{" "}
|
||||
{formatLocalizedDueDays(rule.dueDays)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rule.dueDays === 0
|
||||
? t("settings.paymentTerms.details.sameDay")
|
||||
: t("settings.paymentTerms.details.baseDate")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentTermSegmentedBar
|
||||
formatDueDays={formatLocalizedDueDays}
|
||||
formatPercentage={formatLocalizedPercentage}
|
||||
segments={segments}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { PaymentTermListRow } from "../../../../payment-terms/shared";
|
||||
|
||||
interface PaymentTermRowActionsProps {
|
||||
paymentTerm: PaymentTermListRow;
|
||||
onEdit: (paymentTerm: PaymentTermListRow) => void;
|
||||
onToggleActive: (paymentTerm: PaymentTermListRow) => void;
|
||||
onDelete: (paymentTerm: PaymentTermListRow) => void;
|
||||
activeDisabledReason?: string;
|
||||
deleteDisabledReason?: string;
|
||||
isTogglingActive?: boolean;
|
||||
}
|
||||
|
||||
export const PaymentTermRowActions = ({
|
||||
paymentTerm,
|
||||
onEdit,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
activeDisabledReason,
|
||||
deleteDisabledReason,
|
||||
isTogglingActive = false,
|
||||
}: PaymentTermRowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editDisabled = paymentTerm.isSystem;
|
||||
const toggleDisabled = paymentTerm.isSystem || isTogglingActive;
|
||||
const deleteDisabled = paymentTerm.isSystem;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 xl:w-auto xl:justify-end">
|
||||
<Button
|
||||
aria-label={t("settings.paymentTerms.actions.edit")}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={editDisabled}
|
||||
onClick={() => onEdit(paymentTerm)}
|
||||
size="sm"
|
||||
title={editDisabled ? t("settings.paymentTerms.protections.systemEdit") : undefined}
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.paymentTerms.actions.edit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-busy={isTogglingActive}
|
||||
aria-label={
|
||||
paymentTerm.isActive
|
||||
? t("settings.paymentTerms.actions.disable")
|
||||
: t("settings.paymentTerms.actions.enable")
|
||||
}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={toggleDisabled}
|
||||
onClick={() => onToggleActive(paymentTerm)}
|
||||
size="sm"
|
||||
title={toggleDisabled ? activeDisabledReason : undefined}
|
||||
variant="outline"
|
||||
>
|
||||
{isTogglingActive ? <Spinner aria-hidden className="mr-2 size-4" /> : null}
|
||||
{paymentTerm.isActive
|
||||
? t("settings.paymentTerms.actions.disable")
|
||||
: t("settings.paymentTerms.actions.enable")}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("settings.paymentTerms.actions.more")}
|
||||
className="shrink-0"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
render={
|
||||
<DropdownMenuItem
|
||||
disabled={deleteDisabled}
|
||||
onClick={() => onDelete(paymentTerm)}
|
||||
title={deleteDisabled ? deleteDisabledReason : undefined}
|
||||
variant="destructive"
|
||||
>
|
||||
{t("settings.paymentTerms.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,96 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
type PaymentTermSegment = {
|
||||
dueDays: number;
|
||||
percentage: number;
|
||||
fraction: number;
|
||||
};
|
||||
|
||||
interface PaymentTermSegmentedBarProps {
|
||||
segments: PaymentTermSegment[];
|
||||
formatPercentage: (value: number) => string;
|
||||
formatDueDays: (value: number) => string;
|
||||
}
|
||||
|
||||
const SEGMENT_COLOR_CLASSNAMES = [
|
||||
"bg-sky-500/15 text-sky-700 ring-sky-500/25 dark:bg-sky-400/20 dark:text-sky-100 dark:ring-sky-400/30",
|
||||
"bg-emerald-500/15 text-emerald-700 ring-emerald-500/25 dark:bg-emerald-400/20 dark:text-emerald-100 dark:ring-emerald-400/30",
|
||||
"bg-amber-500/15 text-amber-700 ring-amber-500/25 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-400/30",
|
||||
"bg-fuchsia-500/15 text-fuchsia-700 ring-fuchsia-500/25 dark:bg-fuchsia-400/20 dark:text-fuchsia-100 dark:ring-fuchsia-400/30",
|
||||
];
|
||||
|
||||
export const PaymentTermSegmentedBar = ({
|
||||
segments,
|
||||
formatPercentage,
|
||||
formatDueDays,
|
||||
}: PaymentTermSegmentedBarProps) => {
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridTemplateColumns = segments
|
||||
.map((segment) => `${Math.max(segment.percentage, 1)}fr`)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="hidden gap-2 sm:grid"
|
||||
style={{
|
||||
gridTemplateColumns,
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-md px-3 text-xs font-semibold tabular-nums ring-1",
|
||||
SEGMENT_COLOR_CLASSNAMES[index % SEGMENT_COLOR_CLASSNAMES.length]
|
||||
)}
|
||||
key={`${segment.dueDays}-${segment.percentage}-${segment.fraction}`}
|
||||
title={`${formatPercentage(segment.percentage)} · ${formatDueDays(segment.dueDays)}`}
|
||||
>
|
||||
{formatPercentage(segment.percentage)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:hidden">
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
className="space-y-2"
|
||||
key={`${segment.dueDays}-${segment.percentage}-${segment.fraction}-mobile`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-md px-3 text-sm font-semibold tabular-nums ring-1",
|
||||
SEGMENT_COLOR_CLASSNAMES[index % SEGMENT_COLOR_CLASSNAMES.length]
|
||||
)}
|
||||
title={`${formatPercentage(segment.percentage)} · ${formatDueDays(segment.dueDays)}`}
|
||||
>
|
||||
{formatPercentage(segment.percentage)}
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground tabular-nums">
|
||||
{formatDueDays(segment.dueDays)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden gap-2 sm:grid"
|
||||
style={{
|
||||
gridTemplateColumns,
|
||||
}}
|
||||
>
|
||||
{segments.map((segment) => (
|
||||
<div
|
||||
className="text-center text-xs text-muted-foreground tabular-nums"
|
||||
key={`${segment.dueDays}-${segment.percentage}-${segment.fraction}-label`}
|
||||
>
|
||||
{formatDueDays(segment.dueDays)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
|
||||
export const PaymentTermsEmptyState = ({ onCreate }: { onCreate: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/20 p-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{t("settings.paymentTerms.empty.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.paymentTerms.empty.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="mt-6" onClick={onCreate}>
|
||||
{t("settings.paymentTerms.actions.create")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
|
||||
export const PaymentTermsNoResultsState = ({
|
||||
onClear,
|
||||
}: {
|
||||
onClear: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/15 p-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{t("settings.paymentTerms.emptyFiltered.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.paymentTerms.emptyFiltered.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="mt-6" onClick={onClear} variant="outline">
|
||||
{t("settings.paymentTerms.emptyFiltered.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { Skeleton } from "@repo/shadcn-ui/components";
|
||||
|
||||
export const PaymentTermsSettingsSkeleton = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<Skeleton className="h-4 w-96 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
<Skeleton className="h-10 w-full md:w-80" />
|
||||
<Skeleton className="h-10 w-full md:w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-80 w-full" />
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./payment-terms-settings-page";
|
||||
@ -0,0 +1,228 @@
|
||||
import { PageErrorState } from "@erp/core/components";
|
||||
import { RdxPageContainer, RdxPageHeader, RdxPageStack } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import { usePaymentTermsSettingsPageController } from "../../controllers";
|
||||
import {
|
||||
DeletePaymentTermDialog,
|
||||
PaymentTermFormDialog,
|
||||
PaymentTermListItem,
|
||||
PaymentTermsEmptyState,
|
||||
PaymentTermsNoResultsState,
|
||||
PaymentTermsSettingsSkeleton,
|
||||
} from "../components";
|
||||
|
||||
export const PaymentTermsSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const ctrl = usePaymentTermsSettingsPageController();
|
||||
const featureCtrl = ctrl.controller;
|
||||
|
||||
if (ctrl.isInitialLoading) {
|
||||
return <PaymentTermsSettingsSkeleton />;
|
||||
}
|
||||
|
||||
if (ctrl.pageError) {
|
||||
return (
|
||||
<RdxPageContainer width="full">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={ctrl.pageError.description}
|
||||
primaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: ctrl.isRetrying
|
||||
? t("settings.paymentTerms.pageError.actions.retrying")
|
||||
: t("settings.paymentTerms.pageError.actions.retry"),
|
||||
onClick: ctrl.retry,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
reference={ctrl.pageError.reference}
|
||||
title={ctrl.pageError.title}
|
||||
variant={ctrl.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</RdxPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const activeDisabledReason = (isSystem: boolean) => {
|
||||
if (isSystem) {
|
||||
return t("settings.paymentTerms.protections.systemDisable");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deleteDisabledReason = (isSystem: boolean) => {
|
||||
if (isSystem) {
|
||||
return t("settings.paymentTerms.protections.systemDelete");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target as unknown as { value: string };
|
||||
ctrl.setSearch(target.value);
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: "all" | "active" | "inactive" | null) => {
|
||||
ctrl.setStatus(value ?? "all");
|
||||
};
|
||||
|
||||
return (
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack className="mx-auto w-full max-w-6xl" spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<Button className="w-full sm:w-auto" onClick={featureCtrl.openCreateDialog}>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("settings.paymentTerms.actions.create")}
|
||||
</Button>
|
||||
}
|
||||
description={t("settings.paymentTerms.description")}
|
||||
title={t("settings.paymentTerms.title")}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-border/60 bg-card p-4 shadow-none md:p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative w-full lg:w-96 xl:w-[30rem]">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("settings.paymentTerms.toolbar.searchPlaceholder")}
|
||||
className="pl-9 pr-10"
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t("settings.paymentTerms.toolbar.searchPlaceholder")}
|
||||
value={ctrl.search}
|
||||
/>
|
||||
{ctrl.isBackgroundFetching ? (
|
||||
<Spinner
|
||||
aria-hidden
|
||||
className="absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
|
||||
<span className="text-sm font-medium text-muted-foreground sm:whitespace-nowrap">
|
||||
{t("settings.paymentTerms.toolbar.statusLabel")}
|
||||
</span>
|
||||
<Select onValueChange={handleStatusChange} value={ctrl.status}>
|
||||
<SelectTrigger
|
||||
aria-label={t("settings.paymentTerms.toolbar.statusLabel")}
|
||||
className="w-full sm:min-w-44 lg:w-44"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("settings.paymentTerms.toolbar.statusOptions.all")}
|
||||
</SelectItem>
|
||||
<SelectItem value="active">
|
||||
{t("settings.paymentTerms.toolbar.statusOptions.active")}
|
||||
</SelectItem>
|
||||
<SelectItem value="inactive">
|
||||
{t("settings.paymentTerms.toolbar.statusOptions.inactive")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground lg:justify-end">
|
||||
{ctrl.isBackgroundFetching ? <Spinner aria-hidden className="size-4" /> : null}
|
||||
<span>{t("settings.paymentTerms.toolbar.totalCount", { count: ctrl.totalItems })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ctrl.hasEmptyState ? (
|
||||
<PaymentTermsEmptyState onCreate={featureCtrl.openCreateDialog} />
|
||||
) : ctrl.hasNoResults ? (
|
||||
<PaymentTermsNoResultsState onClear={ctrl.clearFilters} />
|
||||
) : (
|
||||
<div aria-busy={ctrl.isBackgroundFetching} className="space-y-4">
|
||||
{ctrl.list.map((paymentTerm) => (
|
||||
<PaymentTermListItem
|
||||
activeDisabledReason={activeDisabledReason(paymentTerm.isSystem)}
|
||||
deleteDisabledReason={deleteDisabledReason(paymentTerm.isSystem)}
|
||||
isTogglingActive={featureCtrl.isTogglingActive}
|
||||
key={paymentTerm.id}
|
||||
onDelete={featureCtrl.openDeleteDialog}
|
||||
onEdit={featureCtrl.openEditDialog}
|
||||
onToggleActive={(item) => {
|
||||
if (item.isActive) {
|
||||
featureCtrl.disable(item);
|
||||
return;
|
||||
}
|
||||
|
||||
featureCtrl.enable(item);
|
||||
}}
|
||||
paymentTerm={paymentTerm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentTermFormDialog
|
||||
description={t("settings.paymentTerms.create.description")}
|
||||
form={featureCtrl.form}
|
||||
formId={featureCtrl.formId}
|
||||
isEditMode={false}
|
||||
isSubmitting={featureCtrl.isSaving}
|
||||
onSubmit={featureCtrl.onSubmit}
|
||||
open={featureCtrl.isCreateOpen}
|
||||
openChange={(open) => {
|
||||
if (!open) {
|
||||
featureCtrl.closeCreateDialog();
|
||||
}
|
||||
}}
|
||||
submitLabel={t("settings.paymentTerms.actions.create")}
|
||||
title={t("settings.paymentTerms.create.title")}
|
||||
/>
|
||||
|
||||
<PaymentTermFormDialog
|
||||
description={t("settings.paymentTerms.edit.description")}
|
||||
form={featureCtrl.form}
|
||||
formId={`${featureCtrl.formId}-edit`}
|
||||
isEditMode
|
||||
isSubmitting={featureCtrl.isSaving || featureCtrl.isEditingPaymentTermLoading}
|
||||
onSubmit={featureCtrl.onSubmit}
|
||||
open={Boolean(featureCtrl.editingPaymentTerm) || featureCtrl.isEditingPaymentTermLoading}
|
||||
openChange={(open) => {
|
||||
if (!open) {
|
||||
featureCtrl.closeEditDialog();
|
||||
}
|
||||
}}
|
||||
submitLabel={t("settings.paymentTerms.actions.save")}
|
||||
title={t("settings.paymentTerms.edit.title")}
|
||||
/>
|
||||
|
||||
<DeletePaymentTermDialog
|
||||
isSubmitting={featureCtrl.isDeleting}
|
||||
onConfirm={featureCtrl.confirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
featureCtrl.closeDeleteDialog();
|
||||
}
|
||||
}}
|
||||
open={Boolean(featureCtrl.deletingPaymentTerm)}
|
||||
target={featureCtrl.deletingPaymentTerm}
|
||||
/>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import type { PaymentTerm } from "../../../payment-terms/shared";
|
||||
import type { PaymentTermForm } from "../entities";
|
||||
import {
|
||||
formatPaymentTermPercentageInput,
|
||||
sortPaymentTermDueRules,
|
||||
} from "./payment-term-due-rules.utils";
|
||||
|
||||
export const buildPaymentTermFormDefault = (): PaymentTermForm => ({
|
||||
name: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
dueRules: [{ dueDays: "0", percentage: "100,00" }],
|
||||
});
|
||||
|
||||
export const buildPaymentTermFormFromEntity = (paymentTerm: PaymentTerm): PaymentTermForm => ({
|
||||
name: paymentTerm.name,
|
||||
description: paymentTerm.description ?? "",
|
||||
isActive: paymentTerm.isActive,
|
||||
dueRules: sortPaymentTermDueRules(paymentTerm.dueRules).map((rule) => ({
|
||||
dueDays: String(rule.dueDays),
|
||||
percentage: formatPaymentTermPercentageInput(rule.percentage),
|
||||
})),
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import type { CreatePaymentTermParams, UpdatePaymentTermParams } from "../../../payment-terms/shared";
|
||||
import type { PaymentTermForm } from "../entities";
|
||||
import {
|
||||
parsePaymentTermDueDays,
|
||||
parsePaymentTermPercentage,
|
||||
sortPaymentTermDueRules,
|
||||
toPaymentTermPercentageDto,
|
||||
} from "./payment-term-due-rules.utils";
|
||||
|
||||
const buildDueRulesDto = (form: PaymentTermForm) => {
|
||||
return sortPaymentTermDueRules(
|
||||
form.dueRules.map((rule) => ({
|
||||
dueDays: parsePaymentTermDueDays(rule.dueDays) ?? 0,
|
||||
percentage: parsePaymentTermPercentage(rule.percentage) ?? 0,
|
||||
}))
|
||||
).map((rule) => ({
|
||||
due_days: String(rule.dueDays),
|
||||
percentage: toPaymentTermPercentageDto(rule.percentage),
|
||||
}));
|
||||
};
|
||||
|
||||
export const buildCreatePaymentTermParams = (
|
||||
id: string,
|
||||
form: PaymentTermForm
|
||||
): CreatePaymentTermParams => ({
|
||||
id,
|
||||
data: {
|
||||
id,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() === "" ? null : form.description.trim(),
|
||||
is_active: form.isActive,
|
||||
due_rules: buildDueRulesDto(form),
|
||||
},
|
||||
});
|
||||
|
||||
export const buildUpdatePaymentTermParams = (
|
||||
paymentTermId: string,
|
||||
form: PaymentTermForm
|
||||
): UpdatePaymentTermParams => ({
|
||||
paymentTermId,
|
||||
data: {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() === "" ? null : form.description.trim(),
|
||||
is_active: form.isActive,
|
||||
due_rules: buildDueRulesDto(form),
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildPaymentTermsListCriteria,
|
||||
hasPaymentTermsListCriteriaApplied,
|
||||
} from "./build-payment-terms-list-criteria.ts";
|
||||
|
||||
test("construye criteria sin q ni filtro cuando el estado es todos", () => {
|
||||
assert.deepEqual(buildPaymentTermsListCriteria({ search: " ", status: "all" }), {
|
||||
q: undefined,
|
||||
filters: undefined,
|
||||
pageNumber: 0,
|
||||
pageSize: 9999,
|
||||
orderBy: "name",
|
||||
order: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
test("construye criteria con búsqueda y filtro de activos", () => {
|
||||
assert.deepEqual(buildPaymentTermsListCriteria({ search: " contado ", status: "active" }), {
|
||||
q: "contado",
|
||||
filters: [{ field: "isActive", operator: "EQUALS", value: "true" }],
|
||||
pageNumber: 0,
|
||||
pageSize: 9999,
|
||||
orderBy: "name",
|
||||
order: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
test("detecta cuándo hay criterios aplicados", () => {
|
||||
assert.equal(hasPaymentTermsListCriteriaApplied({ search: "", status: "all" }), false);
|
||||
assert.equal(hasPaymentTermsListCriteriaApplied({ search: "30/60", status: "all" }), true);
|
||||
assert.equal(hasPaymentTermsListCriteriaApplied({ search: "", status: "inactive" }), true);
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import type { CriteriaDTO } from "@erp/core";
|
||||
|
||||
export type PaymentTermsStatusFilter = "all" | "active" | "inactive";
|
||||
|
||||
const PAYMENT_TERMS_LIST_PAGE_SIZE = 9999;
|
||||
|
||||
export const buildPaymentTermsListCriteria = (params: {
|
||||
search: string;
|
||||
status: PaymentTermsStatusFilter;
|
||||
}): CriteriaDTO => {
|
||||
const normalizedSearch = params.search.trim();
|
||||
const filters =
|
||||
params.status === "all"
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
field: "isActive",
|
||||
operator: "EQUALS" as const,
|
||||
value: params.status === "active" ? "true" : "false",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
q: normalizedSearch.length > 0 ? normalizedSearch : undefined,
|
||||
filters,
|
||||
pageNumber: 0,
|
||||
pageSize: PAYMENT_TERMS_LIST_PAGE_SIZE,
|
||||
orderBy: "name",
|
||||
order: "asc",
|
||||
};
|
||||
};
|
||||
|
||||
export const hasPaymentTermsListCriteriaApplied = (params: {
|
||||
search: string;
|
||||
status: PaymentTermsStatusFilter;
|
||||
}) => {
|
||||
return params.search.trim().length > 0 || params.status !== "all";
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { isApiClientError } from "@erp/core/client";
|
||||
|
||||
import type { PaymentTermsSettingsPageErrorViewModel } from "../entities";
|
||||
|
||||
export const buildPaymentTermsSettingsPageErrorViewModel = (
|
||||
error: unknown,
|
||||
translate: (key: string) => string
|
||||
): PaymentTermsSettingsPageErrorViewModel => {
|
||||
if (!isApiClientError(error)) {
|
||||
return {
|
||||
variant: "server",
|
||||
title: translate("settings.paymentTerms.pageError.unknown.title"),
|
||||
description: translate("settings.paymentTerms.pageError.unknown.description"),
|
||||
canRetry: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.kind === "forbidden") {
|
||||
return {
|
||||
variant: "forbidden",
|
||||
title: translate("settings.paymentTerms.pageError.forbidden.title"),
|
||||
description: translate("settings.paymentTerms.pageError.forbidden.description"),
|
||||
reference: error.correlationId,
|
||||
canRetry: false,
|
||||
};
|
||||
}
|
||||
|
||||
const variant =
|
||||
error.kind === "network" ||
|
||||
error.kind === "timeout" ||
|
||||
error.kind === "unavailable" ||
|
||||
error.kind === "notFound"
|
||||
? error.kind
|
||||
: "server";
|
||||
|
||||
return {
|
||||
variant,
|
||||
title: translate(`settings.paymentTerms.pageError.${variant}.title`),
|
||||
description: translate(`settings.paymentTerms.pageError.${variant}.description`),
|
||||
reference: error.correlationId,
|
||||
canRetry: true,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
export * from "./build-payment-term-form";
|
||||
export * from "./build-payment-term-params";
|
||||
export * from "./build-payment-terms-list-criteria";
|
||||
export * from "./build-payment-terms-settings-page-error-view-model";
|
||||
export * from "./payment-term-due-rules.utils";
|
||||
export * from "./resolve-payment-terms-settings-feedback-state";
|
||||
@ -0,0 +1,149 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildPaymentTermDueRuleSegments,
|
||||
buildPaymentTermPercentageStatus,
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths,
|
||||
getPaymentTermInstallmentCount,
|
||||
getPaymentTermLastDueDays,
|
||||
getPaymentTermPercentageDistribution,
|
||||
sortPaymentTermDueRules,
|
||||
} from "./payment-term-due-rules.utils.ts";
|
||||
|
||||
const buildDueRules = (...percentages: string[]) => {
|
||||
return percentages.map((percentage, index) => ({
|
||||
dueDays: String(index * 30),
|
||||
percentage,
|
||||
}));
|
||||
};
|
||||
|
||||
const translate = (key: string, params?: Record<string, unknown>) => {
|
||||
switch (key) {
|
||||
case "settings.paymentTerms.form.total.valid":
|
||||
return "La distribución suma exactamente 100,00%.";
|
||||
case "settings.paymentTerms.form.total.missing":
|
||||
return `Falta ${params?.amount as string} para llegar al 100,00%.`;
|
||||
case "settings.paymentTerms.form.total.exceeded":
|
||||
return `Excede ${params?.amount as string} el 100,00%.`;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
test("calcula 100,00% con una única regla al 100%", () => {
|
||||
assert.equal(
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths(buildDueRules("100")),
|
||||
10000
|
||||
);
|
||||
});
|
||||
|
||||
test("calcula 100,00% con dos reglas al 50%", () => {
|
||||
assert.equal(
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths(buildDueRules("50", "50")),
|
||||
10000
|
||||
);
|
||||
});
|
||||
|
||||
test("calcula 99,00% con tres reglas al 33%", () => {
|
||||
assert.equal(
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths(buildDueRules("33", "33", "33")),
|
||||
9900
|
||||
);
|
||||
});
|
||||
|
||||
test("calcula 100,00% con 33,33 + 33,33 + 33,34", () => {
|
||||
assert.equal(
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths(
|
||||
buildDueRules("33,33", "33,33", "33,34")
|
||||
),
|
||||
10000
|
||||
);
|
||||
});
|
||||
|
||||
test("calcula 100,00% con cuatro reglas al 25%", () => {
|
||||
assert.equal(
|
||||
calculatePaymentTermDueRulesTotalPercentageInHundredths(
|
||||
buildDueRules("25", "25", "25", "25")
|
||||
),
|
||||
10000
|
||||
);
|
||||
});
|
||||
|
||||
test("describe correctamente el 99,00% pendiente", () => {
|
||||
assert.deepEqual(buildPaymentTermPercentageStatus(9900, translate, "es"), {
|
||||
tone: "warning",
|
||||
description: "Falta 1,00% para llegar al 100,00%.",
|
||||
});
|
||||
});
|
||||
|
||||
test("describe correctamente el 101,00% excedido", () => {
|
||||
assert.deepEqual(buildPaymentTermPercentageStatus(10100, translate, "es"), {
|
||||
tone: "destructive",
|
||||
description: "Excede 1,00% el 100,00%.",
|
||||
});
|
||||
});
|
||||
|
||||
test("ordena las due rules por días ascendente", () => {
|
||||
assert.deepEqual(
|
||||
sortPaymentTermDueRules([
|
||||
{ dueDays: 60, percentage: 33 },
|
||||
{ dueDays: 0, percentage: 34 },
|
||||
{ dueDays: 30, percentage: 33 },
|
||||
]),
|
||||
[
|
||||
{ dueDays: 0, percentage: 34 },
|
||||
{ dueDays: 30, percentage: 33 },
|
||||
{ dueDays: 60, percentage: 33 },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("calcula el número de cuotas", () => {
|
||||
assert.equal(
|
||||
getPaymentTermInstallmentCount([
|
||||
{ dueDays: 0, percentage: 50 },
|
||||
{ dueDays: 30, percentage: 50 },
|
||||
]),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
test("devuelve null como último vencimiento cuando no hay reglas", () => {
|
||||
assert.equal(getPaymentTermLastDueDays([]), null);
|
||||
});
|
||||
|
||||
test("devuelve el último vencimiento ordenando reglas desordenadas", () => {
|
||||
assert.equal(
|
||||
getPaymentTermLastDueDays([
|
||||
{ dueDays: 90, percentage: 34 },
|
||||
{ dueDays: 30, percentage: 33 },
|
||||
{ dueDays: 60, percentage: 33 },
|
||||
]),
|
||||
90
|
||||
);
|
||||
});
|
||||
|
||||
test("construye la distribución de porcentajes en orden de vencimiento", () => {
|
||||
assert.deepEqual(
|
||||
getPaymentTermPercentageDistribution([
|
||||
{ dueDays: 30, percentage: 33 },
|
||||
{ dueDays: 90, percentage: 34 },
|
||||
{ dueDays: 60, percentage: 33 },
|
||||
]),
|
||||
[33, 33, 34]
|
||||
);
|
||||
});
|
||||
|
||||
test("construye segmentos proporcionales preservando el día 0", () => {
|
||||
assert.deepEqual(
|
||||
buildPaymentTermDueRuleSegments([
|
||||
{ dueDays: 30, percentage: 50 },
|
||||
{ dueDays: 0, percentage: 50 },
|
||||
]),
|
||||
[
|
||||
{ dueDays: 0, percentage: 50, fraction: 0.5 },
|
||||
{ dueDays: 30, percentage: 50, fraction: 0.5 },
|
||||
]
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,186 @@
|
||||
import type { PaymentTermDueRule } from "../../../payment-terms/shared";
|
||||
import type { PaymentTermForm } from "../entities";
|
||||
|
||||
const PERCENTAGE_SCALE = 2;
|
||||
const MAX_PERCENTAGE_HUNDREDTHS = 10000;
|
||||
const PAYMENT_TERM_PERCENTAGE_PATTERN = /^\d+(?:[.,]\d{1,2})?$/;
|
||||
const PAYMENT_TERM_DUE_DAYS_PATTERN = /^\d+$/;
|
||||
|
||||
const buildFormatter = (locale?: string, minimumFractionDigits = 0) => {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits: PERCENTAGE_SCALE,
|
||||
});
|
||||
};
|
||||
|
||||
export type PaymentTermDueRuleSegment = {
|
||||
dueDays: number;
|
||||
percentage: number;
|
||||
fraction: number;
|
||||
};
|
||||
|
||||
export const comparePaymentTermDueRules = (left: PaymentTermDueRule, right: PaymentTermDueRule) => {
|
||||
return left.dueDays - right.dueDays;
|
||||
};
|
||||
|
||||
export const sortPaymentTermDueRules = <TRule extends PaymentTermDueRule>(rules: TRule[]) => {
|
||||
return [...rules].sort(comparePaymentTermDueRules);
|
||||
};
|
||||
|
||||
export const getPaymentTermInstallmentCount = (rules: PaymentTermDueRule[]) => {
|
||||
return rules.length;
|
||||
};
|
||||
|
||||
export const getPaymentTermLastDueDays = (rules: PaymentTermDueRule[]) => {
|
||||
const lastRule = sortPaymentTermDueRules(rules).at(-1);
|
||||
return lastRule?.dueDays ?? null;
|
||||
};
|
||||
|
||||
export const getPaymentTermPercentageDistribution = (rules: PaymentTermDueRule[]) => {
|
||||
return sortPaymentTermDueRules(rules).map((rule) => rule.percentage);
|
||||
};
|
||||
|
||||
export const buildPaymentTermDueRuleSegments = (
|
||||
rules: PaymentTermDueRule[]
|
||||
): PaymentTermDueRuleSegment[] => {
|
||||
return sortPaymentTermDueRules(rules).map((rule) => ({
|
||||
dueDays: rule.dueDays,
|
||||
percentage: rule.percentage,
|
||||
fraction: rule.percentage / 100,
|
||||
}));
|
||||
};
|
||||
|
||||
export const toPaymentTermPercentageHundredths = (value: number) => {
|
||||
return Math.round(value * 100);
|
||||
};
|
||||
|
||||
export const parsePaymentTermPercentageToHundredths = (value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue.length === 0 || !PAYMENT_TERM_PERCENTAGE_PATTERN.test(trimmedValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = trimmedValue.replace(",", ".");
|
||||
const [integerPart, decimalPart = ""] = normalizedValue.split(".");
|
||||
const hundredths = Number(integerPart) * 100 + Number(decimalPart.padEnd(2, "0"));
|
||||
|
||||
if (!Number.isInteger(hundredths) || hundredths <= 0 || hundredths > MAX_PERCENTAGE_HUNDREDTHS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hundredths;
|
||||
};
|
||||
|
||||
export const parsePaymentTermPercentage = (value: string) => {
|
||||
const hundredths = parsePaymentTermPercentageToHundredths(value);
|
||||
|
||||
if (hundredths === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hundredths / 100;
|
||||
};
|
||||
|
||||
export const calculatePaymentTermDueRulesTotalPercentageInHundredths = (
|
||||
rules: PaymentTermForm["dueRules"] | undefined
|
||||
) => {
|
||||
return (rules ?? []).reduce((sum, rule) => {
|
||||
const percentage = parsePaymentTermPercentageToHundredths(rule?.percentage ?? "");
|
||||
|
||||
if (percentage === null) {
|
||||
return sum;
|
||||
}
|
||||
|
||||
return sum + percentage;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const toPaymentTermPercentageDto = (value: number) => {
|
||||
return {
|
||||
value: String(toPaymentTermPercentageHundredths(value)),
|
||||
scale: String(PERCENTAGE_SCALE),
|
||||
};
|
||||
};
|
||||
|
||||
export const formatPaymentTermPercentageInput = (value: number) => {
|
||||
return value.toFixed(PERCENTAGE_SCALE).replace(".", ",");
|
||||
};
|
||||
|
||||
export const parsePaymentTermDueDays = (value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue.length === 0 || !PAYMENT_TERM_DUE_DAYS_PATTERN.test(trimmedValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dueDays = Number(trimmedValue);
|
||||
|
||||
if (!Number.isInteger(dueDays) || dueDays < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dueDays;
|
||||
};
|
||||
|
||||
export const formatPaymentTermPercentage = (value: number, locale?: string) => {
|
||||
return `${buildFormatter(locale, PERCENTAGE_SCALE).format(value)}%`;
|
||||
};
|
||||
|
||||
export const formatPaymentTermTotalPercentage = (valueInHundredths: number, locale?: string) => {
|
||||
return `${buildFormatter(locale, PERCENTAGE_SCALE).format(valueInHundredths / 100)}%`;
|
||||
};
|
||||
|
||||
export const formatPaymentTermDueDays = (
|
||||
value: number,
|
||||
translate: (key: string, params?: Record<string, unknown>) => string
|
||||
) => {
|
||||
if (value === 0) {
|
||||
return translate("settings.paymentTerms.dueRules.sameDay");
|
||||
}
|
||||
|
||||
return translate("settings.paymentTerms.dueRules.days", { count: value });
|
||||
};
|
||||
|
||||
export const formatPaymentTermDueRuleSummary = (
|
||||
rule: PaymentTermDueRule,
|
||||
translate: (key: string, params?: Record<string, unknown>) => string,
|
||||
locale?: string
|
||||
) => {
|
||||
return `${formatPaymentTermPercentage(rule.percentage, locale)} · ${formatPaymentTermDueDays(
|
||||
rule.dueDays,
|
||||
translate
|
||||
)}`;
|
||||
};
|
||||
|
||||
export const buildPaymentTermPercentageStatus = (
|
||||
valueInHundredths: number,
|
||||
translate: (key: string, params?: Record<string, unknown>) => string,
|
||||
locale?: string
|
||||
) => {
|
||||
if (valueInHundredths === MAX_PERCENTAGE_HUNDREDTHS) {
|
||||
return {
|
||||
tone: "success" as const,
|
||||
description: translate("settings.paymentTerms.form.total.valid"),
|
||||
};
|
||||
}
|
||||
|
||||
const difference = Math.abs(MAX_PERCENTAGE_HUNDREDTHS - valueInHundredths);
|
||||
const formattedDifference = formatPaymentTermTotalPercentage(difference, locale);
|
||||
|
||||
if (valueInHundredths < MAX_PERCENTAGE_HUNDREDTHS) {
|
||||
return {
|
||||
tone: "warning" as const,
|
||||
description: translate("settings.paymentTerms.form.total.missing", {
|
||||
amount: formattedDifference,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "destructive" as const,
|
||||
description: translate("settings.paymentTerms.form.total.exceeded", {
|
||||
amount: formattedDifference,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { isApiClientError } from "@erp/core/client";
|
||||
|
||||
export const resolvePaymentTermsSettingsFeedbackState = (params: {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
}) => {
|
||||
const isCancelled = isApiClientError(params.error) && params.error.kind === "cancelled";
|
||||
|
||||
return {
|
||||
isInitialLoading: params.isLoading,
|
||||
hasBlockingError: params.isError && !isCancelled,
|
||||
};
|
||||
};
|
||||
17
scripts/dev/check-environment.sh
Normal file
17
scripts/dev/check-environment.sh
Normal file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "PWD: $(pwd)"
|
||||
echo "Node: $(node --version)"
|
||||
echo "PNPM: $(pnpm --version)"
|
||||
echo "Git root: $(git rev-parse --show-toplevel)"
|
||||
|
||||
test "$(uname -s)" = "Linux"
|
||||
|
||||
test -f pnpm-workspace.yaml
|
||||
test -f turbo.json
|
||||
test -d modules
|
||||
test -d apps
|
||||
|
||||
echo "Development environment OK"
|
||||
Loading…
Reference in New Issue
Block a user