Fase 3: Implementación de Listings (Avisos) - Entidades, Repositorio, API y Frontend Wizard integración

This commit is contained in:
2025-12-17 13:51:48 -03:00
parent f1bd25ea79
commit 1b88394b00
12 changed files with 353 additions and 4 deletions

View File

@@ -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<AttributeDefinition[]>([]);
useEffect(() => {
if (selectedCategory) {
wizardService.getAttributes(selectedCategory.id).then(setDefinitions);
}
}, [selectedCategory]);
return (
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
@@ -30,8 +41,8 @@ function App() {
{step === 1 && <CategorySelection />}
{step === 2 && <OperationSelection />}
{step === 3 && <AttributeForm />}
{step === 4 && <div className="text-center py-20">Paso 4: Fotos (Coming Soon)</div>}
{step === 5 && <div className="text-center py-20">Paso 5: Resumen y Pago (Coming Soon)</div>}
{step === 4 && <div className="text-center py-20">Paso 4: Fotos (Coming Soon) - <button onClick={() => useWizardStore.getState().setStep(5)} className="text-blue-500 underline">Saltar</button></div>}
{step === 5 && <SummaryStep definitions={definitions} />}
</main>
</div>
);

View File

@@ -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<number | null>(null);
const handlePublish = async () => {
if (!selectedCategory || !selectedOperation) return;
setIsSubmitting(true);
try {
const attributePayload: Record<number, string> = {};
// 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 (
<StepWrapper>
<div className="text-center py-10">
<div className="text-4xl text-green-500 mb-4"></div>
<h2 className="text-2xl font-bold mb-2">¡Aviso Publicado!</h2>
<p className="text-gray-500">ID de referencia: #{createdId}</p>
<button onClick={() => window.location.reload()} className="mt-8 text-brand-600 underline">Publicar otro</button>
</div>
</StepWrapper>
);
}
return (
<StepWrapper>
<h2 className="text-2xl font-bold mb-6 text-brand-900">Resumen y Confirmación</h2>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm mb-6">
<div className="mb-4">
<span className="block text-xs text-slate-500 uppercase tracking-wide">Categoría</span>
<span className="font-semibold text-lg">{selectedCategory?.name}</span>
</div>
<div className="mb-4">
<span className="block text-xs text-slate-500 uppercase tracking-wide">Operación</span>
<span className="font-semibold text-lg">{selectedOperation?.name}</span>
</div>
<div className="border-t pt-4 mt-4">
<h3 className="font-bold mb-2">{attributes['title']}</h3>
<div className="text-2xl font-bold text-green-600 mb-4">$ {attributes['price']}</div>
<div className="grid grid-cols-2 gap-2 text-sm">
{definitions.map(def => attributes[def.name] && (
<div key={def.id}>
<span className="text-slate-500">{def.name}:</span> <span className="font-medium">{attributes[def.name]}</span>
</div>
))}
</div>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => setStep(3)}
className="flex-1 py-3 text-slate-600 hover:bg-slate-100 rounded-lg"
disabled={isSubmitting}
>
Volver
</button>
<button
onClick={handlePublish}
disabled={isSubmitting}
className="flex-1 py-3 bg-brand-600 text-white font-bold rounded-lg hover:bg-brand-700 disabled:opacity-50"
>
{isSubmitting ? 'Publicando...' : 'Confirmar y Publicar'}
</button>
</div>
</StepWrapper>
);
}

View File

@@ -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<Category[]> => {
@@ -15,5 +15,10 @@ export const wizardService = {
getAttributes: async (categoryId: number): Promise<AttributeDefinition[]> => {
const response = await api.get<AttributeDefinition[]>(`/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;
}
};

View File

@@ -0,0 +1,15 @@
export interface CreateListingDto {
categoryId: number;
operationId: number;
title: string;
description: string;
price: number;
currency: string;
userId?: number;
attributes: Record<string, string>; // definitionId -> Value
}
export interface Listing {
id: number;
// ...
}

View File

@@ -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<IActionResult> 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<IActionResult> Get(int id)
{
var listing = await _repository.GetByIdAsync(id);
if (listing == null) return NotFound();
return Ok(listing);
}
}

View File

@@ -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<int, string> Attributes { get; set; } = new();
}
public class ListingDto : CreateListingDto
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public string Status { get; set; }
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,10 @@
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface IListingRepository
{
Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes);
Task<Listing?> GetByIdAsync(int id);
Task<IEnumerable<Listing>> GetAllAsync();
}

View File

@@ -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);
}

View File

@@ -18,6 +18,7 @@ public static class DependencyInjection
services.AddScoped<ITokenService, Services.TokenService>();
services.AddScoped<IAuthService, Services.AuthService>();
services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>();
services.AddScoped<IListingRepository, ListingRepository>();
return services;
}
}
@@ -25,3 +26,4 @@ public static class DependencyInjection

View File

@@ -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<int> CreateAsync(Listing listing, Dictionary<int, string> 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<int>(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<Listing?> GetByIdAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
}
public async Task<IEnumerable<Listing>> 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<Listing>(sql);
}
}