feat(F-086): completed feature
This commit is contained in:
@@ -186,6 +186,9 @@ export default function ProductsPage() {
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Marca
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Caducidad
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Estado
|
||||
</th>
|
||||
@@ -232,6 +235,27 @@ export default function ProductsPage() {
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600">{p.brand?.name ?? '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.expirationDate ? (
|
||||
(() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const exp = new Date(p.expirationDate);
|
||||
const expired = exp < today;
|
||||
return (
|
||||
<span
|
||||
className={`text-sm ${expired ? 'text-red-600 font-semibold' : 'text-gray-600'}`}
|
||||
title={expired ? 'Producto caducado' : 'Caduca el'}
|
||||
>
|
||||
{expired ? '⚠ ' : ''}
|
||||
{exp.toLocaleDateString('es-ES')}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StateBadge state={p.state} />
|
||||
</td>
|
||||
|
||||
@@ -64,6 +64,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDesc, setSeoDesc] = useState('');
|
||||
const [seoDescManual, setSeoDescManual] = useState(false);
|
||||
const [expirationDate, setExpirationDate] = useState('');
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
@@ -88,8 +89,8 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
* on every render — a fetch loop that broke checkbox selection.
|
||||
*/
|
||||
const getSnap = useCallback(() => JSON.stringify({
|
||||
name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc,
|
||||
}), [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc]);
|
||||
name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, expirationDate,
|
||||
}), [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, expirationDate]);
|
||||
|
||||
// Keep a ref to the latest getSnap so the load effect (which uses an empty
|
||||
// deps list to avoid the loop) can still compute the initial snapshot.
|
||||
@@ -109,6 +110,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
setState(p.state);
|
||||
setSeoTitle((p as any).seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDesc((p as any).seoDescription ?? ''); setSeoDescManual(true);
|
||||
setExpirationDate((p as any).expirationDate ?? '');
|
||||
snapRef.current = getSnapRef.current();
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
@@ -122,7 +124,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
dirtyRef.current = getSnap() !== snapRef.current;
|
||||
}, [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, loading, getSnap]);
|
||||
}, [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, expirationDate, loading, getSnap]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } };
|
||||
@@ -151,6 +153,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
state,
|
||||
seoTitle: seoTitle || undefined,
|
||||
seoDescription: seoDesc || undefined,
|
||||
expirationDate: expirationDate || undefined,
|
||||
};
|
||||
let saved: Product;
|
||||
if (isCreate) saved = await productsApi.create(payload);
|
||||
@@ -385,6 +388,21 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
<div className="mt-1 text-xs text-gray-400">{seoDesc.length}/160</div>
|
||||
</div>
|
||||
|
||||
{/* Fecha de caducidad */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Fecha de caducidad</label>
|
||||
<span className="text-xs text-gray-400">opcional</span>
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
value={expirationDate}
|
||||
onChange={(e) => setExpirationDate(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface Product {
|
||||
categoryIds?: string[];
|
||||
brand?: { id: string; name: string; slug: string };
|
||||
imageUrl?: string;
|
||||
expirationDate?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
11
project/migrations/033_product_expiration_date.js
Normal file
11
project/migrations/033_product_expiration_date.js
Normal file
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = async (pgm) => {
|
||||
pgm.addColumn('catalog_products', {
|
||||
expiration_date: { type: 'date', notNull: false },
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async (pgm) => {
|
||||
pgm.dropColumn('catalog_products', 'expiration_date');
|
||||
};
|
||||
@@ -92,6 +92,7 @@ const newProductSchema = z.object({
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
categoryIds: z.array(z.uuid()).max(50).optional(),
|
||||
brandId: z.uuid().optional().nullable(),
|
||||
expirationDate: z.iso.date().optional().nullable(),
|
||||
});
|
||||
|
||||
const productPatchSchema = newProductSchema
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Product {
|
||||
categoryIds: string[];
|
||||
brandId: string | null;
|
||||
brand?: ProductBrandSummary;
|
||||
expirationDate: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -66,6 +67,7 @@ export interface NewProduct {
|
||||
seoDescription?: string | null;
|
||||
categoryIds?: string[];
|
||||
brandId?: string | null;
|
||||
expirationDate?: string | null;
|
||||
}
|
||||
|
||||
/** Fields a product update may set. Undefined = leave unchanged. */
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ProductRow {
|
||||
category_ids: string[] | null;
|
||||
brand_name: string | null;
|
||||
brand_slug: string | null;
|
||||
expiration_date: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -53,6 +54,7 @@ const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
['brandId', 'brand_id'],
|
||||
['expirationDate', 'expiration_date'],
|
||||
];
|
||||
|
||||
export class PgProductRepository implements ProductRepository {
|
||||
@@ -91,8 +93,8 @@ export class PgProductRepository implements ProductRepository {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await client.query<ProductRow>(
|
||||
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
|
||||
[
|
||||
input.name,
|
||||
@@ -102,6 +104,7 @@ export class PgProductRepository implements ProductRepository {
|
||||
input.seoTitle ?? null,
|
||||
input.seoDescription ?? null,
|
||||
input.brandId ?? null,
|
||||
input.expirationDate ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -292,6 +295,7 @@ export function toProduct(row: ProductRow): Product {
|
||||
? { id: row.brand_id, name: row.brand_name, slug: row.brand_slug }
|
||||
: undefined,
|
||||
categoryIds: row.category_ids ?? [],
|
||||
expirationDate: row.expiration_date ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
|
||||
seoDescription: null,
|
||||
categoryIds: [],
|
||||
brandId: null,
|
||||
expirationDate: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
|
||||
@@ -20,6 +20,7 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
|
||||
seoDescription: null,
|
||||
categoryIds: [],
|
||||
brandId: null,
|
||||
expirationDate: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
|
||||
Reference in New Issue
Block a user