feat(F-140): completed feature
This commit is contained in:
@@ -525,6 +525,77 @@ export default function OrderDetailPage() {
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* F-140: Customer + Addresses + Payment */}
|
||||
{(order.email || order.customerName) && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Cliente</h2>
|
||||
<div className="space-y-1 text-sm">
|
||||
{order.customerName && <p className="font-medium text-gray-900">{order.customerName}</p>}
|
||||
{order.email && <p className="text-gray-600">{order.email}</p>}
|
||||
{(order.phone || order.customerPhone) && (
|
||||
<p className="text-gray-600">{order.customerPhone || order.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.shippingAddress && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Dirección de envío</h2>
|
||||
<div className="space-y-1 text-sm text-gray-700">
|
||||
<p className="font-medium">{order.shippingAddress.recipientName}</p>
|
||||
<p>{order.shippingAddress.street}</p>
|
||||
<p>{order.shippingAddress.postalCode} {order.shippingAddress.city}</p>
|
||||
<p>{order.shippingAddress.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.billingAddress && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Dirección de facturación</h2>
|
||||
<div className="space-y-1 text-sm text-gray-700">
|
||||
<p className="font-medium">{order.billingAddress.recipientName}</p>
|
||||
<p>{order.billingAddress.street}</p>
|
||||
<p>{order.billingAddress.postalCode} {order.billingAddress.city}</p>
|
||||
<p>{order.billingAddress.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.payment && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Pago</h2>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Método</span>
|
||||
<span className="font-medium text-gray-900 capitalize">{order.payment.provider.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
{order.payment.last4 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Últimos 4</span>
|
||||
<span className="font-mono font-medium text-gray-900">····{order.payment.last4}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Importe</span>
|
||||
<span className="font-medium text-gray-900">{formatPrice(order.payment.amountCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Estado</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
order.payment.status === 'succeeded' ? 'bg-green-100 text-green-800' :
|
||||
order.payment.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
order.payment.status === 'refunded' ? 'bg-amber-100 text-amber-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{order.payment.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* F-140 already shown above — keep existing Envío section */}
|
||||
{/* Envío */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Envío</h2>
|
||||
|
||||
@@ -99,6 +99,22 @@ export interface OrderItem {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface PaymentInfo {
|
||||
provider: string;
|
||||
status: string;
|
||||
amountCents: number;
|
||||
last4: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -111,7 +127,14 @@ export interface Order {
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
idempotencyKey: string | null;
|
||||
customerName?: string | null;
|
||||
customerPhone?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
items: OrderItem[];
|
||||
shippingAddress?: Address | null;
|
||||
billingAddress?: Address | null;
|
||||
payment?: PaymentInfo | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
33
project/migrations/050_order_detail_address_type.js
Normal file
33
project/migrations/050_order_detail_address_type.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
// ── F-140: add type to users_addresses to distinguish shipping from billing ──
|
||||
pgm.sql(`
|
||||
ALTER TABLE users_addresses
|
||||
ADD COLUMN type text NOT NULL DEFAULT 'shipping'
|
||||
CHECK (type IN ('shipping', 'billing'));
|
||||
`);
|
||||
pgm.sql(`CREATE INDEX users_addresses_user_id_type_idx ON users_addresses (user_id, type);`);
|
||||
|
||||
// ── F-140: add phone to identity_users for customer contact ──────────────────
|
||||
pgm.sql(`
|
||||
ALTER TABLE identity_users
|
||||
ADD COLUMN phone text;
|
||||
`);
|
||||
pgm.sql(`CREATE INDEX identity_users_phone_idx ON identity_users (phone) WHERE phone IS NOT NULL;`);
|
||||
|
||||
// ── F-140: add customer_name to orders_orders (captured at checkout) ──────────
|
||||
pgm.sql(`
|
||||
ALTER TABLE orders_orders
|
||||
ADD COLUMN customer_name text,
|
||||
ADD COLUMN customer_phone text;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS customer_name, DROP COLUMN IF EXISTS customer_phone;`);
|
||||
pgm.sql(`ALTER TABLE identity_users DROP COLUMN IF EXISTS phone;`);
|
||||
pgm.sql(`DROP INDEX IF EXISTS users_addresses_user_id_type_idx;`);
|
||||
pgm.sql(`ALTER TABLE users_addresses DROP COLUMN IF EXISTS type;`);
|
||||
};
|
||||
@@ -497,11 +497,14 @@ function serializeOrder(order: {
|
||||
totalCents: number;
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
customerName?: string | null;
|
||||
customerPhone?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
idempotencyKey: string | null;
|
||||
userId?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
productId: string;
|
||||
@@ -515,11 +518,17 @@ function serializeOrder(order: {
|
||||
quantity: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
shippingAddress?: { recipientName: string; street: string; city: string; postalCode: string; country: string } | null;
|
||||
billingAddress?: { recipientName: string; street: string; city: string; postalCode: string; country: string } | null;
|
||||
payment?: { provider: string; status: string; amountCents: number; last4: string | null; createdAt: Date } | null;
|
||||
}) {
|
||||
return {
|
||||
id: order.id,
|
||||
userId: order.userId,
|
||||
email: order.email ?? null,
|
||||
phone: order.phone ?? null,
|
||||
customerName: order.customerName ?? null,
|
||||
customerPhone: order.customerPhone ?? null,
|
||||
state: order.state,
|
||||
currency: order.currency,
|
||||
subtotalCents: order.subtotalCents,
|
||||
@@ -542,6 +551,9 @@ function serializeOrder(order: {
|
||||
quantity: item.quantity,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
})),
|
||||
shippingAddress: order.shippingAddress ?? null,
|
||||
billingAddress: order.billingAddress ?? null,
|
||||
payment: order.payment ? { ...order.payment, createdAt: order.payment.createdAt.toISOString() } : null,
|
||||
createdAt: order.createdAt.toISOString(),
|
||||
updatedAt: order.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,22 @@ export interface OrderItem extends OrderItemInput {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface PaymentInfo {
|
||||
provider: string;
|
||||
status: string;
|
||||
amountCents: number;
|
||||
last4: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -40,6 +56,8 @@ export interface Order {
|
||||
totalCents: number;
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
customerName?: string | null;
|
||||
customerPhone?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -47,6 +65,10 @@ export interface Order {
|
||||
export interface OrderView extends Order {
|
||||
items: OrderItem[];
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
shippingAddress?: Address | null;
|
||||
billingAddress?: Address | null;
|
||||
payment?: PaymentInfo | null;
|
||||
}
|
||||
|
||||
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
|
||||
|
||||
@@ -15,9 +15,28 @@ interface OrderRow {
|
||||
total_cents: number;
|
||||
tracking_number: string | null;
|
||||
courier: string | null;
|
||||
customer_name: string | null;
|
||||
customer_phone: string | null;
|
||||
phone: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface AddressRow {
|
||||
recipient_name: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface PaymentRow {
|
||||
provider: string;
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
provider_payment_id: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
interface ItemRow {
|
||||
id: string;
|
||||
order_id: string;
|
||||
@@ -154,7 +173,10 @@ export class PgOrderRepository implements OrderRepository {
|
||||
|
||||
async findById(id: string): Promise<OrderView | undefined> {
|
||||
const orderResult = await this.pool.query<OrderRow>(
|
||||
'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1',
|
||||
`SELECT o.*, u.email, u.phone
|
||||
FROM orders_orders o
|
||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||
WHERE o.id = $1`,
|
||||
[id],
|
||||
);
|
||||
const order = orderResult.rows[0];
|
||||
@@ -163,12 +185,32 @@ export class PgOrderRepository implements OrderRepository {
|
||||
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
|
||||
[id],
|
||||
);
|
||||
return toOrderView(order, items.rows.map(toItem));
|
||||
const [shippingRow] = await this.pool.query<AddressRow>(
|
||||
`SELECT recipient_name, street, city, postal_code, country
|
||||
FROM users_addresses WHERE user_id = $1 AND type = 'shipping' LIMIT 1`,
|
||||
[order.user_id],
|
||||
).then((r) => r.rows);
|
||||
const [billingRow] = await this.pool.query<AddressRow>(
|
||||
`SELECT recipient_name, street, city, postal_code, country
|
||||
FROM users_addresses WHERE user_id = $1 AND type = 'billing' LIMIT 1`,
|
||||
[order.user_id],
|
||||
).then((r) => r.rows);
|
||||
const [paymentRow] = await this.pool.query<PaymentRow>(
|
||||
`SELECT provider, status, amount_cents, provider_payment_id, created_at
|
||||
FROM payments_transactions
|
||||
WHERE order_id = $1 AND status = 'succeeded'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[id],
|
||||
).then((r) => r.rows);
|
||||
return toOrderView(order, items.rows.map(toItem), shippingRow, billingRow, paymentRow);
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined> {
|
||||
const orderResult = await this.pool.query<OrderRow>(
|
||||
'SELECT o.*, u.email FROM orders_orders o LEFT JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1 AND o.user_id = $2',
|
||||
`SELECT o.*, u.email, u.phone
|
||||
FROM orders_orders o
|
||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||
WHERE o.id = $1 AND o.user_id = $2`,
|
||||
[id, userId],
|
||||
);
|
||||
const order = orderResult.rows[0];
|
||||
@@ -257,11 +299,24 @@ function toOrder(row: OrderRow): Order {
|
||||
};
|
||||
}
|
||||
|
||||
function toOrderView(row: OrderRow, items: OrderItem[]): OrderView {
|
||||
function toOrderView(
|
||||
row: OrderRow,
|
||||
items: OrderItem[],
|
||||
shipping?: AddressRow | null,
|
||||
billing?: AddressRow | null,
|
||||
payment?: PaymentRow | null,
|
||||
): OrderView {
|
||||
const last4 = payment?.provider_payment_id
|
||||
? payment.provider_payment_id.replace(/[^0-9]/g, '').slice(-4)
|
||||
: null;
|
||||
return {
|
||||
...toOrder(row),
|
||||
email: row.email,
|
||||
phone: row.phone ?? null,
|
||||
items,
|
||||
shippingAddress: shipping ? { recipientName: shipping.recipient_name, street: shipping.street, city: shipping.city, postalCode: shipping.postal_code, country: shipping.country } : null,
|
||||
billingAddress: billing ? { recipientName: billing.recipient_name, street: billing.street, city: billing.city, postalCode: billing.postal_code, country: billing.country } : null,
|
||||
payment: payment ? { provider: payment.provider, status: payment.status, amountCents: payment.amount_cents, last4, createdAt: payment.created_at } : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user