Compare commits
8 Commits
0e2e4c9c94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a231c206e | |||
| bcb0c94fc5 | |||
| 2aae873a4b | |||
| 3a534f7ad3 | |||
| dfeb5fb7e1 | |||
| 3e7c4bfde9 | |||
| 0eab947975 | |||
| ee36d86b5a |
@@ -34,6 +34,14 @@ database/
|
|||||||
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||||
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||||
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
||||||
|
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
|
||||||
|
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
|
||||||
|
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
|
||||||
|
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
|
||||||
|
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
|
||||||
|
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
|
||||||
|
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
|
||||||
|
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
|
|||||||
20
database/migrations/V025_ROLLBACK.sql
Normal file
20
database/migrations/V025_ROLLBACK.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- V025_ROLLBACK.sql
|
||||||
|
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
|
||||||
|
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE ProductTypeId IS NOT NULL
|
||||||
|
AND ValidTo IS NULL
|
||||||
|
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||||
|
AND ProductTypeId IN (
|
||||||
|
SELECT Id FROM dbo.ProductType
|
||||||
|
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
|
||||||
|
);
|
||||||
|
|
||||||
|
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
|
||||||
|
GO
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- V025__seed_chargeable_char_overrides_demo.sql
|
||||||
|
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
|
||||||
|
--
|
||||||
|
-- Estrategia:
|
||||||
|
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
|
||||||
|
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
|
||||||
|
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
|
||||||
|
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
|
||||||
|
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
|
||||||
|
-- los overrides que falten (MERGE idempotente).
|
||||||
|
--
|
||||||
|
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
|
||||||
|
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
|
||||||
|
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
|
||||||
|
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
|
||||||
|
--
|
||||||
|
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
|
||||||
|
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||||
|
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||||
|
|
||||||
|
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||||
|
|
||||||
|
-- Clasificado overrides
|
||||||
|
IF @ClasificadoId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||||
|
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||||
|
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||||
|
|
||||||
|
-- Notables overrides
|
||||||
|
IF @NotablesId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||||
|
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||||
|
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||||
|
|
||||||
|
-- Fúnebres overrides
|
||||||
|
IF @FunebresId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||||
|
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
|
||||||
|
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
|
||||||
|
GO
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.WordCounter;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ public sealed class CreateChargeableCharConfigCommandValidator
|
|||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.WithMessage("Symbol no puede estar vacío.")
|
.WithMessage("Symbol no puede estar vacío.")
|
||||||
.MaximumLength(4)
|
.MaximumLength(4)
|
||||||
.WithMessage("Symbol no puede exceder 4 caracteres.");
|
.WithMessage("Symbol no puede exceder 4 caracteres.")
|
||||||
|
.Must(s => !WordCounterService.ContainsEmoji(s))
|
||||||
|
.WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
|
||||||
|
|
||||||
RuleFor(x => x.Category)
|
RuleFor(x => x.Category)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
@@ -28,8 +31,8 @@ public sealed class CreateChargeableCharConfigCommandValidator
|
|||||||
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
||||||
|
|
||||||
RuleFor(x => x.PricePerUnit)
|
RuleFor(x => x.PricePerUnit)
|
||||||
.GreaterThan(0m)
|
.GreaterThanOrEqualTo(0m)
|
||||||
.WithMessage("PricePerUnit debe ser > 0.");
|
.WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
|
||||||
|
|
||||||
RuleFor(x => x.ValidFrom)
|
RuleFor(x => x.ValidFrom)
|
||||||
.GreaterThanOrEqualTo(today)
|
.GreaterThanOrEqualTo(today)
|
||||||
|
|||||||
@@ -96,6 +96,22 @@ public sealed class WordCounterService
|
|||||||
return new WordCountResult(tokens.Length, counts);
|
return new WordCountResult(tokens.Length, counts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the given string contains any emoji codepoint.
|
||||||
|
/// Used by validators that must reject emojis in user-facing identifiers
|
||||||
|
/// (e.g. ChargeableCharConfig.Symbol) where the frontend blocker can be bypassed
|
||||||
|
/// by direct API calls. Shares the same IsEmojiRune Unicode ranges used by Count().
|
||||||
|
/// </summary>
|
||||||
|
public static bool ContainsEmoji(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return false;
|
||||||
|
foreach (var rune in text.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the given rune is an emoji codepoint.
|
/// Returns true if the given rune is an emoji codepoint.
|
||||||
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
|
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
|
||||||
|
|||||||
88
src/web/package-lock.json
generated
88
src/web/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
@@ -2264,6 +2265,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-id": {
|
"node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ShoppingCart,
|
|
||||||
Calculator,
|
|
||||||
Zap,
|
|
||||||
Settings,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
Users,
|
||||||
|
UserPlus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
FileClock,
|
FileClock,
|
||||||
@@ -19,31 +15,49 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Package,
|
Package,
|
||||||
Hash,
|
Hash,
|
||||||
|
Building2,
|
||||||
|
Calculator,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useSidebarSections } from '@/hooks/useSidebarSections'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
disabled?: boolean
|
|
||||||
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
||||||
requiredPermission?: string
|
requiredPermission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
interface NavSection {
|
||||||
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
label: string
|
||||||
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
|
/** Icon de grupo, usado en el modo colapsado como trigger del fly-out. */
|
||||||
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
|
icon: React.ElementType
|
||||||
{ label: 'Integraciones', href: '/integraciones', icon: Zap, disabled: true },
|
/** Si true, la sección solo se muestra si el user tiene rol admin. */
|
||||||
{ label: 'Administración', href: '/administracion', icon: Settings, disabled: true },
|
adminOnly?: boolean
|
||||||
]
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
const adminItems: NavItem[] = [
|
// Item principal — siempre visible para usuarios autenticados
|
||||||
|
const dashboardItem: NavItem = {
|
||||||
|
label: 'Dashboard',
|
||||||
|
href: '/',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secciones agrupadas por dominio (Seguridad → Maestros → Catálogo → Tasación).
|
||||||
|
// Cada sección se oculta si todos sus items están filtrados por permisos.
|
||||||
|
const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
label: 'Seguridad',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{ label: 'Usuarios', href: '/usuarios', icon: Users },
|
{ label: 'Usuarios', href: '/usuarios', icon: Users },
|
||||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||||
@@ -54,6 +68,13 @@ const adminItems: NavItem[] = [
|
|||||||
icon: FileClock,
|
icon: FileClock,
|
||||||
requiredPermission: 'administracion:auditoria:ver',
|
requiredPermission: 'administracion:auditoria:ver',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Maestros',
|
||||||
|
icon: Building2,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Medios',
|
label: 'Medios',
|
||||||
href: '/admin/medios',
|
href: '/admin/medios',
|
||||||
@@ -72,6 +93,13 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Store,
|
icon: Store,
|
||||||
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Catálogo',
|
||||||
|
icon: Package,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Rubros',
|
label: 'Rubros',
|
||||||
href: '/admin/rubros',
|
href: '/admin/rubros',
|
||||||
@@ -90,12 +118,21 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
requiredPermission: 'catalogo:productos:gestionar',
|
requiredPermission: 'catalogo:productos:gestionar',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tasación',
|
||||||
|
icon: Calculator,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Caracteres Tasables',
|
label: 'Caracteres Tasables',
|
||||||
href: '/admin/tasacion/chargeable-chars',
|
href: '/admin/tasacion/chargeable-chars',
|
||||||
icon: Hash,
|
icon: Hash,
|
||||||
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
@@ -107,11 +144,11 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const isAdmin = user?.rol === 'admin'
|
const isAdmin = user?.rol === 'admin'
|
||||||
const { collapsed: persisted, toggle } = useSidebar()
|
const { collapsed: persisted, toggle: toggleSidebar } = useSidebar()
|
||||||
const collapsed = forceExpanded ? false : persisted
|
const collapsed = forceExpanded ? false : persisted
|
||||||
|
const { isCollapsed: isSectionCollapsed, toggle: toggleSection } = useSidebarSections()
|
||||||
|
|
||||||
function isItemActive(item: NavItem): boolean {
|
function isItemActive(item: NavItem): boolean {
|
||||||
if (item.disabled) return false
|
|
||||||
if (item.href === '/') return pathname === '/'
|
if (item.href === '/') return pathname === '/'
|
||||||
if (item.href === '/usuarios') {
|
if (item.href === '/usuarios') {
|
||||||
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
||||||
@@ -120,6 +157,24 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
return pathname.startsWith(item.href)
|
return pathname.startsWith(item.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAccess(item: NavItem): boolean {
|
||||||
|
return !item.requiredPermission || (user?.permisos.includes(item.requiredPermission) ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if any item in the section matches the active route. */
|
||||||
|
function sectionContainsActive(section: NavSection): boolean {
|
||||||
|
return section.items.some(isItemActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter sections + items by role + permissions. Empty sections are hidden.
|
||||||
|
const visibleSections = navSections
|
||||||
|
.filter((section) => !section.adminOnly || isAdmin)
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter(hasAccess),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.items.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -128,7 +183,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
)}
|
)}
|
||||||
data-collapsed={collapsed}
|
data-collapsed={collapsed}
|
||||||
>
|
>
|
||||||
{/* Brand + Toggle (top header) */}
|
{/* Brand + Toggle */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-14 items-center border-b border-border shrink-0',
|
'flex h-14 items-center border-b border-border shrink-0',
|
||||||
@@ -140,7 +195,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggleSidebar}
|
||||||
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
|
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
|
||||||
>
|
>
|
||||||
@@ -166,34 +221,38 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
|
||||||
{navItems.map((item) => (
|
|
||||||
<NavRow
|
<NavRow
|
||||||
key={item.href}
|
item={dashboardItem}
|
||||||
item={item}
|
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={isItemActive(item)}
|
active={isItemActive(dashboardItem)}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
|
|
||||||
{isAdmin && (
|
{collapsed
|
||||||
<>
|
? visibleSections.map((section) => (
|
||||||
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
<CollapsedSectionFlyout
|
||||||
{adminItems
|
key={section.label}
|
||||||
.filter(
|
section={section}
|
||||||
(item) =>
|
isItemActive={isItemActive}
|
||||||
!item.requiredPermission ||
|
active={sectionContainsActive(section)}
|
||||||
user?.permisos.includes(item.requiredPermission),
|
|
||||||
)
|
|
||||||
.map((item) => (
|
|
||||||
<NavRow
|
|
||||||
key={item.href}
|
|
||||||
item={item}
|
|
||||||
collapsed={collapsed}
|
|
||||||
active={isItemActive(item)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
</>
|
: visibleSections.map((section) => {
|
||||||
)}
|
const containsActive = sectionContainsActive(section)
|
||||||
|
const userPrefCollapsed = isSectionCollapsed(section.label)
|
||||||
|
// Auto-expand override: if the active route lives here, keep it open
|
||||||
|
// regardless of persisted preference.
|
||||||
|
const expanded = containsActive ? true : !userPrefCollapsed
|
||||||
|
return (
|
||||||
|
<ExpandedSection
|
||||||
|
key={section.label}
|
||||||
|
section={section}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggle={() => toggleSection(section.label)}
|
||||||
|
toggleDisabled={containsActive}
|
||||||
|
isItemActive={isItemActive}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
@@ -217,41 +276,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
|
|||||||
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
|
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Disabled item
|
|
||||||
if (item.disabled) {
|
|
||||||
const content = (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
baseClasses,
|
|
||||||
'text-muted-foreground/70 cursor-not-allowed opacity-60',
|
|
||||||
)}
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 truncate">{item.label}</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
|
|
||||||
Próx.
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!collapsed) return content
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{item.label}{' '}
|
|
||||||
<span className="text-muted-foreground">· Próximamente</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active link
|
|
||||||
const link = (
|
const link = (
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
@@ -263,7 +287,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
|
|||||||
)}
|
)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{/* Active indicator bar (left edge) when expanded */}
|
|
||||||
{active && !collapsed && (
|
{active && !collapsed && (
|
||||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
|
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -281,21 +304,138 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionLabel({
|
/* ── Expanded mode — collapsible section with chevron toggle ──────── */
|
||||||
collapsed,
|
|
||||||
children,
|
interface ExpandedSectionProps {
|
||||||
}: {
|
section: NavSection
|
||||||
collapsed: boolean
|
expanded: boolean
|
||||||
children: React.ReactNode
|
onToggle: () => void
|
||||||
}) {
|
/** When true, the header is not clickable (because the section contains the active route and must stay open). */
|
||||||
if (collapsed) {
|
toggleDisabled: boolean
|
||||||
return <div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
|
isItemActive: (item: NavItem) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExpandedSection({
|
||||||
|
section,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
toggleDisabled,
|
||||||
|
isItemActive,
|
||||||
|
}: ExpandedSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-3 pb-1 px-3">
|
<div className="pt-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
<button
|
||||||
{children}
|
type="button"
|
||||||
</span>
|
onClick={toggleDisabled ? undefined : onToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between pt-1 pb-1 px-3 rounded-md',
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60',
|
||||||
|
toggleDisabled
|
||||||
|
? 'cursor-default'
|
||||||
|
: 'hover:text-muted-foreground transition-colors cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{section.label}</span>
|
||||||
|
{!toggleDisabled && (
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3 shrink-0 transition-transform duration-200',
|
||||||
|
expanded ? 'rotate-0' : '-rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<NavRow
|
||||||
|
key={item.href}
|
||||||
|
item={item}
|
||||||
|
collapsed={false}
|
||||||
|
active={isItemActive(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Collapsed mode — group icon + fly-out hover panel ──────────── */
|
||||||
|
|
||||||
|
interface CollapsedSectionFlyoutProps {
|
||||||
|
section: NavSection
|
||||||
|
isItemActive: (item: NavItem) => boolean
|
||||||
|
/** When true, the group icon shows an active indicator (contains the active route). */
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedSectionFlyout({
|
||||||
|
section,
|
||||||
|
isItemActive,
|
||||||
|
active,
|
||||||
|
}: CollapsedSectionFlyoutProps) {
|
||||||
|
const GroupIcon = section.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard openDelay={120} closeDelay={80}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={section.label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center justify-center h-10 w-10 mx-auto rounded-md transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GroupIcon className="h-4 w-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-56 p-2 z-[60]"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="px-2 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||||
|
{section.label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = isItemActive(item)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
role="menuitem"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2 py-1.5 rounded-md text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
238
src/web/src/components/layout/__tests__/AppSidebar.test.tsx
Normal file
238
src/web/src/components/layout/__tests__/AppSidebar.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { SidebarNav } from '../AppSidebar'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado inicial del authStore para cada test.
|
||||||
|
* El rol admin + set de permisos completos activa todas las secciones.
|
||||||
|
*/
|
||||||
|
function setUser(overrides: Partial<{ rol: string; permisos: string[] }> = {}) {
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin Test',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: [
|
||||||
|
'administracion:auditoria:ver',
|
||||||
|
'administracion:medios:gestionar',
|
||||||
|
'administracion:secciones:gestionar',
|
||||||
|
'administracion:puntos_de_venta:gestionar',
|
||||||
|
'catalogo:rubros:gestionar',
|
||||||
|
'catalogo:tipos:gestionar',
|
||||||
|
'catalogo:productos:gestionar',
|
||||||
|
'tasacion:caracteres_especiales:gestionar',
|
||||||
|
],
|
||||||
|
mustChangePassword: false,
|
||||||
|
...overrides,
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar(initialPath = '/') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<SidebarNav forceExpanded />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppSidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setUser()
|
||||||
|
// Clean per-section collapse preferences between tests
|
||||||
|
window.localStorage.removeItem('sidebar-sections-collapsed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Dashboard visible para todo usuario autenticado', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('No muestra items disabled con badge "Próx." (limpieza)', () => {
|
||||||
|
renderSidebar()
|
||||||
|
// Los 4 items removidos del sidebar: Ventas, Tasación (nivel top), Integraciones, Administración (nivel top)
|
||||||
|
expect(screen.queryByText(/próx/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /^ventas$/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /^integraciones$/i })).not.toBeInTheDocument()
|
||||||
|
// "Administración" como link top-level también se elimina (queda solo como label de sección)
|
||||||
|
expect(screen.queryByRole('link', { name: /^administración$/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Muestra las 4 secciones agrupadas para admin con todos los permisos', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByText('Seguridad')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Maestros')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Catálogo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Tasación')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Cada item vive en la sección correcta (tras expandir todas las secciones)', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
// Expandir manualmente las 4 secciones (arrancan colapsadas por default)
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'sidebar-sections-collapsed',
|
||||||
|
JSON.stringify({ Seguridad: false, Maestros: false, Catálogo: false, Tasación: false }),
|
||||||
|
)
|
||||||
|
renderSidebar()
|
||||||
|
void user // keep setup import used
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
expect(screen.getByRole('link', { name: /usuarios/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /crear usuario/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /roles/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /permisos/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /auditoría/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Maestros
|
||||||
|
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /^secciones$/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /puntos de venta/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Catálogo
|
||||||
|
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /tipos de producto/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /^productos$/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Tasación
|
||||||
|
expect(screen.getByRole('link', { name: /caracteres tasables/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Oculta secciones sin items permitidos (ej: user sin permisos de catálogo)', () => {
|
||||||
|
setUser({
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: [
|
||||||
|
// Solo permisos de Seguridad + Maestros
|
||||||
|
'administracion:medios:gestionar',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
|
||||||
|
// Seguridad se muestra (Usuarios/Crear Usuario/Roles/Permisos no requieren permiso custom)
|
||||||
|
expect(screen.getByText('Seguridad')).toBeInTheDocument()
|
||||||
|
// Maestros se muestra (tiene Medios con su permiso)
|
||||||
|
expect(screen.getByText('Maestros')).toBeInTheDocument()
|
||||||
|
// Catálogo desaparece (ningún permiso catalogo:*)
|
||||||
|
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
|
||||||
|
// Tasación desaparece
|
||||||
|
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Items filtrados
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /^secciones$/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Usuario no-admin no ve ninguna sección adminOnly', () => {
|
||||||
|
setUser({ rol: 'cajero', permisos: [] })
|
||||||
|
renderSidebar()
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Seguridad')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Maestros')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Marca el item activo según la ruta actual (Caracteres Tasables)', () => {
|
||||||
|
// Ruta activa contenida en Tasación → auto-expand
|
||||||
|
renderSidebar('/admin/tasacion/chargeable-chars')
|
||||||
|
const link = screen.getByRole('link', { name: /caracteres tasables/i })
|
||||||
|
expect(link).toHaveAttribute('aria-current', 'page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Dashboard activo solo en raíz exacta', () => {
|
||||||
|
const { unmount } = renderSidebar('/')
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('aria-current', 'page')
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).not.toHaveAttribute('aria-current', 'page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Header "SIG-CM 2.0" visible en modo expandido', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByText('SIG-CM 2.0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Orden de secciones: Seguridad → Maestros → Catálogo → Tasación', () => {
|
||||||
|
renderSidebar()
|
||||||
|
const nav = screen.getByRole('navigation')
|
||||||
|
const labels = within(nav).getAllByText(/Seguridad|Maestros|Catálogo|Tasación/)
|
||||||
|
expect(labels.map((n) => n.textContent)).toEqual([
|
||||||
|
'Seguridad',
|
||||||
|
'Maestros',
|
||||||
|
'Catálogo',
|
||||||
|
'Tasación',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Collapse per-section (modo expandido) ──────────────────────────────
|
||||||
|
|
||||||
|
it('Default: todas las secciones arrancan colapsadas EXCEPTO la que contiene la ruta activa', () => {
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
// Maestros contiene la ruta activa → expandida → Medios visible
|
||||||
|
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
|
||||||
|
// Otras secciones colapsadas → sus items NO visibles
|
||||||
|
expect(screen.queryByRole('link', { name: /^usuarios$/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Header de sección activa NO es toggleable (sin chevron, disabled)', () => {
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
const maestrosBtn = screen.getByRole('button', { name: /maestros/i })
|
||||||
|
expect(maestrosBtn).toBeDisabled()
|
||||||
|
expect(maestrosBtn).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Click en header de sección no-activa expande/colapsa sus items', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
|
||||||
|
// Catálogo arranca colapsado → Rubros NO visible
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click en "Catálogo" → expande
|
||||||
|
await user.click(screen.getByRole('button', { name: /catálogo/i }))
|
||||||
|
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click de nuevo → colapsa
|
||||||
|
await user.click(screen.getByRole('button', { name: /catálogo/i }))
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria-expanded refleja estado expandido/colapsado', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
|
||||||
|
const catalogoBtn = screen.getByRole('button', { name: /catálogo/i })
|
||||||
|
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
|
||||||
|
await user.click(catalogoBtn)
|
||||||
|
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Ruta en Dashboard (raíz): ninguna sección contiene active → todas colapsadas', () => {
|
||||||
|
renderSidebar('/')
|
||||||
|
expect(screen.queryByRole('link', { name: /^medios$/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Preferencia de collapse persiste en localStorage', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderSidebar('/')
|
||||||
|
|
||||||
|
// Expandir Catálogo → guarda preferencia
|
||||||
|
await user.click(screen.getByRole('button', { name: /catálogo/i }))
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem('sidebar-sections-collapsed')
|
||||||
|
expect(stored).toBeTruthy()
|
||||||
|
const parsed = JSON.parse(stored!)
|
||||||
|
expect(parsed['Catálogo']).toBe(false) // false = expandido
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/web/src/components/ui/hover-card.tsx
Normal file
29
src/web/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Portal>
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
))
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
61
src/web/src/hooks/useSidebarSections.ts
Normal file
61
src/web/src/hooks/useSidebarSections.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sidebar-sections-collapsed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map: sectionName → isCollapsed.
|
||||||
|
* Missing key = expanded (default).
|
||||||
|
*/
|
||||||
|
export type SectionState = Record<string, boolean>
|
||||||
|
|
||||||
|
function readInitial(): SectionState {
|
||||||
|
if (typeof window === 'undefined') return {}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as SectionState
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages per-section collapse state in the sidebar with localStorage persistence.
|
||||||
|
*
|
||||||
|
* - Initially, ALL sections start collapsed EXCEPT the one containing the active route.
|
||||||
|
* That behavior is enforced at render-time by the caller via `overrideExpanded`
|
||||||
|
* (the section currently active is ALWAYS shown expanded regardless of stored pref).
|
||||||
|
* - `toggle(name)` flips the stored preference for a specific section. The override
|
||||||
|
* for the active section remains unaffected — it will stay expanded while active.
|
||||||
|
* - `isCollapsed(name)` returns the stored preference (does NOT apply the active-route
|
||||||
|
* override — that's the caller's job).
|
||||||
|
*/
|
||||||
|
export function useSidebarSections(defaultCollapsed = true) {
|
||||||
|
const [state, setState] = useState<SectionState>(readInitial)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const isCollapsed = useCallback(
|
||||||
|
(name: string): boolean => {
|
||||||
|
// Missing key → fall back to default (collapsed = true by design).
|
||||||
|
if (!(name in state)) return defaultCollapsed
|
||||||
|
return state[name]
|
||||||
|
},
|
||||||
|
[state, defaultCollapsed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggle = useCallback((name: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const currentlyCollapsed = name in prev ? prev[name] : defaultCollapsed
|
||||||
|
return { ...prev, [name]: !currentlyCollapsed }
|
||||||
|
})
|
||||||
|
}, [defaultCollapsed])
|
||||||
|
|
||||||
|
return { isCollapsed, toggle }
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@ export function ProtectedLayout({ children }: ProtectedLayoutProps) {
|
|||||||
<div className="absolute top-[-15%] right-[-8%] w-[500px] h-[500px] rounded-full bg-brand-500/10 dark:bg-brand-500/12 blur-[140px] pointer-events-none" />
|
<div className="absolute top-[-15%] right-[-8%] w-[500px] h-[500px] rounded-full bg-brand-500/10 dark:bg-brand-500/12 blur-[140px] pointer-events-none" />
|
||||||
<div className="absolute bottom-[-15%] left-[20%] w-[500px] h-[500px] rounded-full bg-violet-500/8 dark:bg-violet-500/10 blur-[140px] pointer-events-none" />
|
<div className="absolute bottom-[-15%] left-[20%] w-[500px] h-[500px] rounded-full bg-violet-500/8 dark:bg-violet-500/10 blur-[140px] pointer-events-none" />
|
||||||
|
|
||||||
{/* Desktop sidebar — width controlled by SidebarNav itself (collapsed/expanded) */}
|
{/* Desktop sidebar — width controlled by SidebarNav itself (collapsed/expanded).
|
||||||
<div className="relative z-10 hidden lg:flex lg:flex-col lg:shrink-0 border-r border-border">
|
z-30 keeps the sidebar above page content; fly-out HoverCard uses z-[60] to
|
||||||
|
guarantee it sits above any app-level overlay (except modal dialogs). */}
|
||||||
|
<div className="relative z-30 hidden lg:flex lg:flex-col lg:shrink-0 border-r border-border">
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,9 +243,11 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Post_InvalidPrice_Returns400ValidationFailure()
|
public async Task Post_InvalidPrice_Returns400ValidationFailure()
|
||||||
{
|
{
|
||||||
|
// PRC-001 followup #57: PricePerUnit >= 0 is now valid (opt-in billing).
|
||||||
|
// Use -1 to still exercise the negative rejection path.
|
||||||
var token = GetAdminToken();
|
var token = GetAdminToken();
|
||||||
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
||||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
|
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = -1m, validFrom = TomorrowStr() },
|
||||||
token: token);
|
token: token);
|
||||||
var resp = await _client.SendAsync(req);
|
var resp = await _client.SendAsync(req);
|
||||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
@@ -276,25 +278,22 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PRC-001-R2.7 — Emoji symbols are explicitly DEFERRED per spec.
|
/// PRC-001 followup #55 — Business decision (2026-04-21): emoji Symbols are NOT allowed.
|
||||||
/// The ChargeableCharConfig Symbol field accepts any 1–4 char value including emojis.
|
/// Validator delegates to WordCounterService.ContainsEmoji which checks every rune against
|
||||||
/// "😀" in C# has string.Length = 2 (UTF-16 surrogate pair), so it passes MaximumLength(4).
|
/// the Unicode emoji ranges (Emoticons, Pictographs, Dingbats, VS-16, ZWJ, etc.).
|
||||||
/// This test documents the deferred behavior: emoji in Symbol is accepted at config level.
|
/// This provides a defensive check beyond the frontend SymbolInput blocker — direct API
|
||||||
/// The EmojiDetectedException applies only to WordCounterService (ad text, not config symbols).
|
/// calls (Postman, adversarial clients) can't bypass it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Post_WithEmojiSymbol_Returns201_BecauseEmojiRejectionIsDeferred()
|
public async Task Post_WithEmojiSymbol_Returns400()
|
||||||
{
|
{
|
||||||
var token = GetAdminToken();
|
var token = GetAdminToken();
|
||||||
// "😀" has C# string.Length == 2 (UTF-16 surrogate pair) — passes MaximumLength(4).
|
|
||||||
// Emoji rejection for config Symbols is deferred to PRC-002+ per spec R2.7.
|
|
||||||
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
||||||
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
||||||
token: token);
|
token: token);
|
||||||
var resp = await _client.SendAsync(req);
|
var resp = await _client.SendAsync(req);
|
||||||
// Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test.
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest,
|
||||||
resp.StatusCode.Should().Be(HttpStatusCode.Created,
|
because: "emoji symbols are rejected by validator via WordCounterService.ContainsEmoji (#55)");
|
||||||
because: "emoji symbol rejection is deferred (spec R2.7). Symbol '😀' has length 2 in C# (UTF-16) → passes MaximumLength(4)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
||||||
|
|||||||
@@ -92,10 +92,13 @@ public class CreateChargeableCharConfigCommandValidatorTests
|
|||||||
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PricePerUnit_Zero_FailsValidation()
|
public void PricePerUnit_Zero_Passes_OptInBilling()
|
||||||
{
|
{
|
||||||
|
// PRC-001 followup #57: opt-in billing — 0 is a valid price meaning
|
||||||
|
// "this Symbol exists but does NOT charge extra for this ProductType".
|
||||||
|
// Aligned with DB check constraint CK_ChargeableCharConfig_Price_NonNegative (>= 0).
|
||||||
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
||||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -112,6 +115,35 @@ public class CreateChargeableCharConfigCommandValidatorTests
|
|||||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Symbol emoji rejection (PRC-001 followup #55) ───────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("😀")] // smiley face (U+1F600, Emoticons block)
|
||||||
|
[InlineData("🚗")] // car (U+1F697, Transport block)
|
||||||
|
[InlineData("🔥")] // fire (U+1F525, Misc Symbols & Pictographs)
|
||||||
|
[InlineData("❤️")] // red heart + VS-16 (U+2764 U+FE0F)
|
||||||
|
[InlineData("☀️")] // sun (U+2600 in Misc Symbols block)
|
||||||
|
public void Symbol_WithEmoji_FailsValidation(string emojiSymbol)
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Symbol = emojiSymbol };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("$")]
|
||||||
|
[InlineData("%")]
|
||||||
|
[InlineData("!")]
|
||||||
|
[InlineData("¡")]
|
||||||
|
[InlineData("@")]
|
||||||
|
[InlineData("€")]
|
||||||
|
[InlineData("##")]
|
||||||
|
[InlineData("ABCD")]
|
||||||
|
public void Symbol_WithoutEmoji_Passes(string plainSymbol)
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Symbol = plainSymbol };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user