From 1b88394b004a26721f7b509d393c1d752c05d09f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 17 Dec 2025 13:51:48 -0300 Subject: [PATCH] =?UTF-8?q?Fase=203:=20Implementaci=C3=B3n=20de=20Listings?= =?UTF-8?q?=20(Avisos)=20-=20Entidades,=20Repositorio,=20API=20y=20Fronten?= =?UTF-8?q?d=20Wizard=20integraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/publish-wizard/src/App.tsx | 17 ++- .../src/pages/Steps/SummaryStep.tsx | 108 ++++++++++++++++++ .../src/services/wizardService.ts | 7 +- frontend/publish-wizard/src/types/listing.ts | 15 +++ .../Controllers/ListingsController.cs | 46 ++++++++ src/SIGCM.Application/DTOs/ListingDtos.cs | 22 ++++ src/SIGCM.Domain/Entities/Listing.cs | 17 +++ .../Entities/ListingAttributeValue.cs | 9 ++ .../Interfaces/IListingRepository.cs | 10 ++ .../Data/DbInitializer.cs | 31 +++++ .../DependencyInjection.cs | 2 + .../Repositories/ListingRepository.cs | 73 ++++++++++++ 12 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx create mode 100644 frontend/publish-wizard/src/types/listing.ts create mode 100644 src/SIGCM.API/Controllers/ListingsController.cs create mode 100644 src/SIGCM.Application/DTOs/ListingDtos.cs create mode 100644 src/SIGCM.Domain/Entities/Listing.cs create mode 100644 src/SIGCM.Domain/Entities/ListingAttributeValue.cs create mode 100644 src/SIGCM.Domain/Interfaces/IListingRepository.cs create mode 100644 src/SIGCM.Infrastructure/Repositories/ListingRepository.cs diff --git a/frontend/publish-wizard/src/App.tsx b/frontend/publish-wizard/src/App.tsx index b846a3e..e86c97c 100644 --- a/frontend/publish-wizard/src/App.tsx +++ b/frontend/publish-wizard/src/App.tsx @@ -1,10 +1,21 @@ +import { useEffect, useState } from 'react'; import { useWizardStore } from './store/wizardStore'; import CategorySelection from './pages/Steps/CategorySelection'; import OperationSelection from './pages/Steps/OperationSelection'; import AttributeForm from './pages/Steps/AttributeForm'; +import SummaryStep from './pages/Steps/SummaryStep'; +import { wizardService } from './services/wizardService'; +import type { AttributeDefinition } from './types'; function App() { - const step = useWizardStore((state) => state.step); + const { step, selectedCategory } = useWizardStore(); + const [definitions, setDefinitions] = useState([]); + + useEffect(() => { + if (selectedCategory) { + wizardService.getAttributes(selectedCategory.id).then(setDefinitions); + } + }, [selectedCategory]); return (
@@ -30,8 +41,8 @@ function App() { {step === 1 && } {step === 2 && } {step === 3 && } - {step === 4 &&
Paso 4: Fotos (Coming Soon)
} - {step === 5 &&
Paso 5: Resumen y Pago (Coming Soon)
} + {step === 4 &&
Paso 4: Fotos (Coming Soon) -
} + {step === 5 && }
); diff --git a/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx b/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx new file mode 100644 index 0000000..c5bbf28 --- /dev/null +++ b/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { useWizardStore } from '../../store/wizardStore'; +import { wizardService } from '../../services/wizardService'; +import { StepWrapper } from '../../components/StepWrapper'; +import type { AttributeDefinition } from '../../types'; + +export default function SummaryStep({ definitions }: { definitions: AttributeDefinition[] }) { + const { selectedCategory, selectedOperation, attributes, setStep } = useWizardStore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [createdId, setCreatedId] = useState(null); + + const handlePublish = async () => { + if (!selectedCategory || !selectedOperation) return; + + setIsSubmitting(true); + try { + const attributePayload: Record = {}; + // Ideally we should have stored definitions in store or passed them. + // For now assuming 'definitions' prop contains current category definitions + + // For now assuming 'definitions' prop contains current category definitions + + definitions.forEach(def => { + if (attributes[def.name]) { + attributePayload[def.id] = attributes[def.name].toString(); + } + }); + + const payload = { + categoryId: selectedCategory.id, + operationId: selectedOperation.id, + title: attributes['title'], + description: 'Generated via Wizard', // Todo: Add description field + price: parseFloat(attributes['price']), + currency: 'ARS', + attributes: attributePayload + }; + + const result = await wizardService.createListing(payload); + setCreatedId(result.id); + } catch (error) { + console.error(error); + alert('Error al publicar'); + } finally { + setIsSubmitting(false); + } + }; + + if (createdId) { + return ( + +
+
+

¡Aviso Publicado!

+

ID de referencia: #{createdId}

+ +
+
+ ); + } + + return ( + +

Resumen y Confirmación

+ +
+
+ Categoría + {selectedCategory?.name} +
+
+ Operación + {selectedOperation?.name} +
+ +
+

{attributes['title']}

+
$ {attributes['price']}
+ +
+ {definitions.map(def => attributes[def.name] && ( +
+ {def.name}: {attributes[def.name]} +
+ ))} +
+
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/publish-wizard/src/services/wizardService.ts b/frontend/publish-wizard/src/services/wizardService.ts index f0106c3..8459e68 100644 --- a/frontend/publish-wizard/src/services/wizardService.ts +++ b/frontend/publish-wizard/src/services/wizardService.ts @@ -1,5 +1,5 @@ import api from './api'; -import { Category, Operation, AttributeDefinition } from '../types'; +import type { Category, Operation, AttributeDefinition } from '../types'; // ID: type-import-fix export const wizardService = { getCategories: async (): Promise => { @@ -15,5 +15,10 @@ export const wizardService = { getAttributes: async (categoryId: number): Promise => { const response = await api.get(`/attributedefinitions/category/${categoryId}`); return response.data; + }, + + createListing: async (data: any): Promise<{ id: number }> => { + const response = await api.post<{ id: number }>('/listings', data); + return response.data; } }; diff --git a/frontend/publish-wizard/src/types/listing.ts b/frontend/publish-wizard/src/types/listing.ts new file mode 100644 index 0000000..02b6051 --- /dev/null +++ b/frontend/publish-wizard/src/types/listing.ts @@ -0,0 +1,15 @@ +export interface CreateListingDto { + categoryId: number; + operationId: number; + title: string; + description: string; + price: number; + currency: string; + userId?: number; + attributes: Record; // definitionId -> Value +} + +export interface Listing { + id: number; + // ... +} diff --git a/src/SIGCM.API/Controllers/ListingsController.cs b/src/SIGCM.API/Controllers/ListingsController.cs new file mode 100644 index 0000000..6a1de53 --- /dev/null +++ b/src/SIGCM.API/Controllers/ListingsController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Mvc; +using SIGCM.Application.DTOs; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; + +namespace SIGCM.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ListingsController : ControllerBase +{ + private readonly IListingRepository _repository; + + public ListingsController(IListingRepository repository) + { + _repository = repository; + } + + [HttpPost] + public async Task Create(CreateListingDto dto) + { + var listing = new Listing + { + CategoryId = dto.CategoryId, + OperationId = dto.OperationId, + Title = dto.Title, + Description = dto.Description, + Price = dto.Price, + Currency = dto.Currency, + UserId = dto.UserId, + Status = "Draft", + CreatedAt = DateTime.UtcNow + }; + + var id = await _repository.CreateAsync(listing, dto.Attributes); + return Ok(new { id }); + } + + [HttpGet("{id}")] + public async Task Get(int id) + { + var listing = await _repository.GetByIdAsync(id); + if (listing == null) return NotFound(); + return Ok(listing); + } +} diff --git a/src/SIGCM.Application/DTOs/ListingDtos.cs b/src/SIGCM.Application/DTOs/ListingDtos.cs new file mode 100644 index 0000000..d4daf06 --- /dev/null +++ b/src/SIGCM.Application/DTOs/ListingDtos.cs @@ -0,0 +1,22 @@ +namespace SIGCM.Application.DTOs; + +public class CreateListingDto +{ + public int CategoryId { get; set; } + public int OperationId { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public decimal Price { get; set; } + public string Currency { get; set; } = "ARS"; + public int? UserId { get; set; } + + // Dictionary of AttributeDefinitionId -> Value + public Dictionary Attributes { get; set; } = new(); +} + +public class ListingDto : CreateListingDto +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public string Status { get; set; } +} diff --git a/src/SIGCM.Domain/Entities/Listing.cs b/src/SIGCM.Domain/Entities/Listing.cs new file mode 100644 index 0000000..2bc9314 --- /dev/null +++ b/src/SIGCM.Domain/Entities/Listing.cs @@ -0,0 +1,17 @@ +namespace SIGCM.Domain.Entities; + +public class Listing +{ + public int Id { get; set; } + public int CategoryId { get; set; } + public int OperationId { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public decimal Price { get; set; } + public string Currency { get; set; } = "ARS"; // ARS, USD + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string Status { get; set; } = "Draft"; // Draft, Published, Sold, Paused + public int? UserId { get; set; } + + // Navigation properties logic will be handled manually via repositories in Dapper +} diff --git a/src/SIGCM.Domain/Entities/ListingAttributeValue.cs b/src/SIGCM.Domain/Entities/ListingAttributeValue.cs new file mode 100644 index 0000000..236eb21 --- /dev/null +++ b/src/SIGCM.Domain/Entities/ListingAttributeValue.cs @@ -0,0 +1,9 @@ +namespace SIGCM.Domain.Entities; + +public class ListingAttributeValue +{ + public int Id { get; set; } + public int ListingId { get; set; } + public int AttributeDefinitionId { get; set; } + public required string Value { get; set; } +} diff --git a/src/SIGCM.Domain/Interfaces/IListingRepository.cs b/src/SIGCM.Domain/Interfaces/IListingRepository.cs new file mode 100644 index 0000000..b153918 --- /dev/null +++ b/src/SIGCM.Domain/Interfaces/IListingRepository.cs @@ -0,0 +1,10 @@ +using SIGCM.Domain.Entities; + +namespace SIGCM.Domain.Interfaces; + +public interface IListingRepository +{ + Task CreateAsync(Listing listing, Dictionary attributes); + Task GetByIdAsync(int id); + Task> GetAllAsync(); +} diff --git a/src/SIGCM.Infrastructure/Data/DbInitializer.cs b/src/SIGCM.Infrastructure/Data/DbInitializer.cs index fbf28f9..b28cbcb 100644 --- a/src/SIGCM.Infrastructure/Data/DbInitializer.cs +++ b/src/SIGCM.Infrastructure/Data/DbInitializer.cs @@ -123,6 +123,37 @@ BEGIN FOREIGN KEY (CategoryId) REFERENCES Categories(Id) ON DELETE CASCADE ); END + +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Listings') +BEGIN + CREATE TABLE Listings ( + Id INT IDENTITY(1,1) PRIMARY KEY, + CategoryId INT NOT NULL, + OperationId INT NOT NULL, + Title NVARCHAR(200) NOT NULL, + Description NVARCHAR(MAX) NULL, + Price DECIMAL(18,2) NOT NULL DEFAULT 0, + Currency NVARCHAR(3) DEFAULT 'ARS', + CreatedAt DATETIME2 DEFAULT GETUTCDATE(), + Status NVARCHAR(20) DEFAULT 'Draft', + UserId INT NULL, + FOREIGN KEY (CategoryId) REFERENCES Categories(Id), + FOREIGN KEY (OperationId) REFERENCES Operations(Id), + FOREIGN KEY (UserId) REFERENCES Users(Id) + ); +END + +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ListingAttributeValues') +BEGIN + CREATE TABLE ListingAttributeValues ( + Id INT IDENTITY(1,1) PRIMARY KEY, + ListingId INT NOT NULL, + AttributeDefinitionId INT NOT NULL, + Value NVARCHAR(MAX) NOT NULL, + FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE, + FOREIGN KEY (AttributeDefinitionId) REFERENCES AttributeDefinitions(Id) ON DELETE NO ACTION + ); +END "; await connection.ExecuteAsync(schemaSql); } diff --git a/src/SIGCM.Infrastructure/DependencyInjection.cs b/src/SIGCM.Infrastructure/DependencyInjection.cs index 33e015b..9dbe5e1 100644 --- a/src/SIGCM.Infrastructure/DependencyInjection.cs +++ b/src/SIGCM.Infrastructure/DependencyInjection.cs @@ -18,6 +18,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } @@ -25,3 +26,4 @@ public static class DependencyInjection + diff --git a/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs b/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs new file mode 100644 index 0000000..e699b5b --- /dev/null +++ b/src/SIGCM.Infrastructure/Repositories/ListingRepository.cs @@ -0,0 +1,73 @@ +using System.Data; +using Dapper; +using SIGCM.Application.DTOs; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; +using SIGCM.Infrastructure.Data; + +namespace SIGCM.Infrastructure.Repositories; + +public class ListingRepository : IListingRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public ListingRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task CreateAsync(Listing listing, Dictionary attributes) + { + using var conn = _connectionFactory.CreateConnection(); + conn.Open(); + using var transaction = conn.BeginTransaction(); + + try + { + var sqlListing = @" + INSERT INTO Listings (CategoryId, OperationId, Title, Description, Price, Currency, CreatedAt, Status, UserId) + VALUES (@CategoryId, @OperationId, @Title, @Description, @Price, @Currency, @CreatedAt, @Status, @UserId); + SELECT CAST(SCOPE_IDENTITY() as int);"; + + var listingId = await conn.QuerySingleAsync(sqlListing, listing, transaction); + + if (attributes != null && attributes.Any()) + { + var sqlAttr = @" + INSERT INTO ListingAttributeValues (ListingId, AttributeDefinitionId, Value) + VALUES (@ListingId, @AttributeDefinitionId, @Value)"; + + foreach (var attr in attributes) + { + await conn.ExecuteAsync(sqlAttr, new { ListingId = listingId, AttributeDefinitionId = attr.Key, Value = attr.Value }, transaction); + } + } + + transaction.Commit(); + return listingId; + } + catch + { + transaction.Rollback(); + throw; + } + } + + public async Task GetByIdAsync(int id) + { + using var conn = _connectionFactory.CreateConnection(); + return await conn.QuerySingleOrDefaultAsync("SELECT * FROM Listings WHERE Id = @Id", new { Id = id }); + } + + public async Task> GetAllAsync() + { + using var conn = _connectionFactory.CreateConnection(); + // A simple query for now + var sql = @" + SELECT l.* + FROM Listings l + ORDER BY l.CreatedAt DESC"; + + return await conn.QueryAsync(sql); + } +}