ADDED: new add notes feature for orders backend and frontend
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import type { Address, AuditEvent, OrderDetail, OrderSummary } from "@orderops/shared";
|
||||
import type { Address, AuditEvent, Note, OrderDetail, OrderSummary } from "@orderops/shared";
|
||||
import { apiRequest } from "../lib/api-client";
|
||||
|
||||
export function listOrders(query: string) {
|
||||
@@ -14,6 +14,13 @@ export function listAuditEvents(orderId: string) {
|
||||
return apiRequest<AuditEvent[]>(`/api/orders/${orderId}/audit-events`);
|
||||
}
|
||||
|
||||
export function addOrderNote(orderId: string, body: string) {
|
||||
return apiRequest<Note>(`/api/orders/${orderId}/notes`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrderItemQuantity(orderId: string, itemId: string, quantity: number) {
|
||||
return apiRequest<OrderDetail>(`/api/orders/${orderId}/items/${itemId}/quantity`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Address, AuditEvent, OrderDetail } from "@orderops/shared";
|
||||
import { AuditTimeline } from "./audit-timeline";
|
||||
import { OrderItemsPanel } from "./order-items-panel";
|
||||
import { OrderNotesPanel } from "./order-notes-panel";
|
||||
import { OrderSummaryPanel } from "./order-summary";
|
||||
import { ShippingAddressForm } from "./shipping-address-form";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface OrderDetailPageProps {
|
||||
onUpdateQuantity: (itemId: string, quantity: number) => Promise<void>;
|
||||
onSaveAddress: (address: Address) => Promise<void>;
|
||||
onCancel: () => Promise<void>;
|
||||
onAddNote: (body: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function OrderDetailPage({
|
||||
@@ -17,7 +19,8 @@ export function OrderDetailPage({
|
||||
auditEvents,
|
||||
onUpdateQuantity,
|
||||
onSaveAddress,
|
||||
onCancel
|
||||
onCancel,
|
||||
onAddNote
|
||||
}: OrderDetailPageProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -29,6 +32,7 @@ export function OrderDetailPage({
|
||||
</div>
|
||||
<OrderItemsPanel order={order} onUpdateQuantity={onUpdateQuantity} />
|
||||
<ShippingAddressForm order={order} onSave={onSaveAddress} />
|
||||
<OrderNotesPanel key={order.id} order={order} onAddNote={onAddNote} />
|
||||
<AuditTimeline events={auditEvents} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import type { OrderDetail } from "@orderops/shared";
|
||||
|
||||
interface OrderNotesPanelProps {
|
||||
order: OrderDetail;
|
||||
onAddNote: (body: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function OrderNotesPanel({ order, onAddNote }: OrderNotesPanelProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const notes = [...order.notes].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-700">Internal notes</h3>
|
||||
{notes.length ? (
|
||||
<ul className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<li key={note.id} className="rounded-lg border border-slate-200 p-3">
|
||||
<div className="text-xs text-slate-500">
|
||||
Author: {note.authorName} {"\u00b7"} <time dateTime={note.createdAt}>{new Date(note.createdAt).toLocaleString()}</time>
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap [overflow-wrap:anywhere] text-sm text-slate-900">{note.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No internal notes yet.</p>
|
||||
)}
|
||||
<form
|
||||
className="mt-4 space-y-3"
|
||||
aria-busy={isPending}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.trim() || body.length >= 2000) {
|
||||
setError("Note must be non-empty and less than 2000 characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onAddNote(body);
|
||||
setBody("");
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : "Failed to add note.");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
<span className="mb-1 block">Add an internal note</span>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 disabled:opacity-50"
|
||||
rows={4}
|
||||
maxLength={1999}
|
||||
required
|
||||
disabled={isPending}
|
||||
value={body}
|
||||
onChange={(event) => {
|
||||
setBody(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={isPending || !body.trim()}
|
||||
>
|
||||
{isPending ? "Adding note..." : "Add note"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import type { Address, AuditEvent, OrderDetail } from "@orderops/shared";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Toast } from "../ui/toast";
|
||||
import { cancelOrder, getOrder, listAuditEvents, updateOrderItemQuantity, updateShippingAddress } from "./order-api";
|
||||
import { addOrderNote, cancelOrder, getOrder, listAuditEvents, updateOrderItemQuantity, updateShippingAddress } from "./order-api";
|
||||
import { OrderDetailPage } from "./order-detail-page";
|
||||
|
||||
interface ToastState {
|
||||
@@ -72,6 +72,33 @@ export function OrderPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNote(body: string) {
|
||||
try {
|
||||
if (!orderId) {
|
||||
throw new Error("Order not found.");
|
||||
}
|
||||
|
||||
setError(null);
|
||||
await addOrderNote(orderId, body);
|
||||
} catch (caught) {
|
||||
setToast({
|
||||
message: caught instanceof Error ? caught.message : "Failed to add note.",
|
||||
variant: "error"
|
||||
});
|
||||
throw caught;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshOrder(orderId);
|
||||
setToast({ message: "Note added.", variant: "success" });
|
||||
} catch {
|
||||
setToast({
|
||||
message: "Note saved, but the page didn't refresh — reload to see it.",
|
||||
variant: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link className="inline-flex text-sm font-medium text-blue-600 hover:text-blue-700" to="/orders">
|
||||
@@ -87,6 +114,7 @@ export function OrderPage() {
|
||||
onUpdateQuantity={(itemId, quantity) => runAction(() => updateOrderItemQuantity(order.id, itemId, quantity), "Quantity saved.")}
|
||||
onSaveAddress={(address: Address) => runAction(() => updateShippingAddress(order.id, address), "Shipping address saved.")}
|
||||
onCancel={() => runAction(() => cancelOrder(order.id), "Order cancelled.")}
|
||||
onAddNote={handleAddNote}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user