feat(orders): add employee only internal order notes #14

Open
dsel wants to merge 1 commits from jd-feat/Add-internal-order-notes into master
11 changed files with 141 additions and 8 deletions
+4
View File
@@ -90,6 +90,7 @@ export function initializeDatabase(sqlite: Database) {
customer_id TEXT NOT NULL REFERENCES users(id), customer_id TEXT NOT NULL REFERENCES users(id),
shipping_address_id TEXT NOT NULL REFERENCES addresses(id), shipping_address_id TEXT NOT NULL REFERENCES addresses(id),
status TEXT NOT NULL, status TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -119,4 +120,7 @@ export function initializeDatabase(sqlite: Database) {
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
`); `);
if (!columnExists(sqlite, "orders", "notes")) {
sqlite.exec("ALTER TABLE orders ADD COLUMN notes TEXT");
}
} }
+1
View File
@@ -9,6 +9,7 @@ export const orders = sqliteTable("orders", {
customerId: text("customer_id").notNull().references(() => users.id), customerId: text("customer_id").notNull().references(() => users.id),
shippingAddressId: text("shipping_address_id").notNull().references(() => addresses.id), shippingAddressId: text("shipping_address_id").notNull().references(() => addresses.id),
status: text("status").notNull(), status: text("status").notNull(),
notes: text("notes"),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull() updatedAt: text("updated_at").notNull()
}); });
+5
View File
@@ -96,6 +96,7 @@ export async function findOrderDetail(context: DatabaseContext, orderId: string)
id: header.order.id, id: header.order.id,
orderNumber: header.order.orderNumber, orderNumber: header.order.orderNumber,
status: header.order.status as OrderDetail["status"], status: header.order.status as OrderDetail["status"],
notes: header.order.notes,
createdAt: header.order.createdAt, createdAt: header.order.createdAt,
updatedAt: header.order.updatedAt, updatedAt: header.order.updatedAt,
customer: { customer: {
@@ -127,6 +128,10 @@ export async function updateOrderTimestamp(context: DatabaseContext, orderId: st
await context.db.update(orders).set({ updatedAt }).where(eq(orders.id, orderId)); await context.db.update(orders).set({ updatedAt }).where(eq(orders.id, orderId));
} }
export async function updateOrderNotes(context: DatabaseContext, orderId: string, notes: string, updatedAt: string) {
await context.db.update(orders).set({ notes, updatedAt }).where(eq(orders.id, orderId));
}
export async function updateOrderStatus(context: DatabaseContext, orderId: string, status: OrderDetail["status"], updatedAt: string) { export async function updateOrderStatus(context: DatabaseContext, orderId: string, status: OrderDetail["status"], updatedAt: string) {
await context.db.update(orders).set({ status, updatedAt }).where(eq(orders.id, orderId)); await context.db.update(orders).set({ status, updatedAt }).where(eq(orders.id, orderId));
} }
+24
View File
@@ -7,6 +7,7 @@ import { getOrder } from "./get-order";
import { listAuditEvents } from "./list-audit-events"; import { listAuditEvents } from "./list-audit-events";
import { listOrders } from "./list-orders"; import { listOrders } from "./list-orders";
import { changeOrderItemQuantity } from "./update-order-item"; import { changeOrderItemQuantity } from "./update-order-item";
import { changeOrderNotes } from "./update-order-notes";
import { changeShippingAddress } from "./update-shipping-address"; import { changeShippingAddress } from "./update-shipping-address";
interface OrderRouteOptions { interface OrderRouteOptions {
@@ -69,6 +70,29 @@ export const registerOrderRoutes: FastifyPluginAsync<OrderRouteOptions> = async
app.get<{ Params: IdParams }>("/orders/:orderId/audit-events", async (request) => listAuditEvents(options.db, request.params.orderId)); app.get<{ Params: IdParams }>("/orders/:orderId/audit-events", async (request) => listAuditEvents(options.db, request.params.orderId));
app.post<{ Params: IdParams; Body: { notes: string } }>("/orders/:orderId/notes", {
schema: {
body: {
type: "object",
required: ["notes"],
properties: {
notes: { type: "string", minLength: 1, maxLength: 1000 }
}
}
}
}, async (request, reply) => {
const result = await changeOrderNotes(options.db, {
orderId: request.params.orderId,
notes: request.body.notes
});
if (result.statusCode !== 200) {
return reply.code(result.statusCode).send({ message: result.message });
}
return result.order;
});
app.post<{ Params: OrderItemParams; Body: QuantityBody }>("/orders/:orderId/items/:itemId/quantity", { app.post<{ Params: OrderItemParams; Body: QuantityBody }>("/orders/:orderId/items/:itemId/quantity", {
schema: { schema: {
body: { body: {
+19
View File
@@ -0,0 +1,19 @@
import type { DatabaseContext } from "../db/database";
import { findOrderDetail, updateOrderNotes } from "./order-repository";
export async function changeOrderNotes(context: DatabaseContext, input: { orderId: string; notes: string }) {
const notes = input.notes.trim();
if (!notes || Array.from(input.notes).length > 1000) {
Review

It would be better to not hard code values here.

It would be better to not hard code values here.
return { statusCode: 400, message: "Internal notes must contain between 1 and 1000 characters." } as const;
}
const order = await findOrderDetail(context, input.orderId);
if (!order) {
return { statusCode: 404, message: "Order not found." } as const;
}
await updateOrderNotes(context, input.orderId, notes, new Date().toISOString());
return { statusCode: 200, order: await findOrderDetail(context, input.orderId) } as const;
}
File diff suppressed because one or more lines are too long
+7
View File
@@ -34,3 +34,10 @@ export function cancelOrder(orderId: string, reason?: string) {
body: JSON.stringify({ reason }) body: JSON.stringify({ reason })
}); });
} }
export function updateOrderNotes(orderId: string, notes: string) {
return apiRequest<OrderDetail>(`/api/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({ notes })
});
}
+13 -2
View File
@@ -1,6 +1,7 @@
import type { Address, AuditEvent, OrderDetail } from "@orderops/shared"; import type { Address, AuditEvent, OrderDetail } from "@orderops/shared";
import { AuditTimeline } from "./audit-timeline"; import { AuditTimeline } from "./audit-timeline";
import { OrderItemsPanel } from "./order-items-panel"; import { OrderItemsPanel } from "./order-items-panel";
import { OrderNotesForm } from "./order-notes-form";
import { OrderSummaryPanel } from "./order-summary"; import { OrderSummaryPanel } from "./order-summary";
import { ShippingAddressForm } from "./shipping-address-form"; import { ShippingAddressForm } from "./shipping-address-form";
@@ -10,6 +11,7 @@ interface OrderDetailPageProps {
onUpdateQuantity: (itemId: string, quantity: number) => Promise<void>; onUpdateQuantity: (itemId: string, quantity: number) => Promise<void>;
onSaveAddress: (address: Address) => Promise<void>; onSaveAddress: (address: Address) => Promise<void>;
onCancel: () => Promise<void>; onCancel: () => Promise<void>;
onSaveNotes: (notes: string) => Promise<boolean>;
} }
export function OrderDetailPage({ export function OrderDetailPage({
@@ -17,12 +19,20 @@ export function OrderDetailPage({
auditEvents, auditEvents,
onUpdateQuantity, onUpdateQuantity,
onSaveAddress, onSaveAddress,
onCancel onCancel,
onSaveNotes
}: OrderDetailPageProps) { }: OrderDetailPageProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<OrderSummaryPanel order={order} /> <OrderSummaryPanel order={order} />
<div className="flex justify-end"> <div className="flex justify-end gap-3">
<button className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white" type="button" onClick={() => {
const input = document.getElementById("internal-order-notes");
input?.scrollIntoView({ block: "center" });
input?.focus({ preventScroll: true });
}}>
{order.notes ? "Edit internal notes" : "Add internal note"}
</button>
<button className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white" onClick={() => void onCancel()} type="button"> <button className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white" onClick={() => void onCancel()} type="button">
Cancel order Cancel order
</button> </button>
@@ -30,6 +40,7 @@ export function OrderDetailPage({
<OrderItemsPanel order={order} onUpdateQuantity={onUpdateQuantity} /> <OrderItemsPanel order={order} onUpdateQuantity={onUpdateQuantity} />
<ShippingAddressForm order={order} onSave={onSaveAddress} /> <ShippingAddressForm order={order} onSave={onSaveAddress} />
<AuditTimeline events={auditEvents} /> <AuditTimeline events={auditEvents} />
<OrderNotesForm key={order.id} notes={order.notes} onSave={onSaveNotes} />
</div> </div>
); );
} }
+58
View File
@@ -0,0 +1,58 @@
import { useState } from "react";
interface OrderNotesFormProps {
notes: string | null;
onSave: (notes: string) => Promise<boolean>;
}
export function OrderNotesForm({ notes, onSave }: OrderNotesFormProps) {
const [draft, setDraft] = useState(notes ?? "");
const [isSaving, setIsSaving] = useState(false);
const length = Array.from(draft).length;
return (
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-3">
<h3 className="text-sm font-semibold text-slate-700">Internal notes</h3>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600">Employees only</span>
</div>
{notes ? <p className="mb-3 whitespace-pre-wrap break-words rounded-lg border border-slate-200 p-3 text-sm text-slate-700">{notes}</p> : null}
<form onSubmit={async (event) => {
event.preventDefault();
if (isSaving || !draft.trim() || length > 1000) return;
Review

It would be better to not hard code values here, and the other spots in this component as well.

It would be better to not hard code values here, and the other spots in this component as well.
setIsSaving(true);
try {
if (await onSave(draft)) setDraft(draft.trim());
} finally {
setIsSaving(false);
}
}}>
<label className="mb-1 block text-sm text-slate-700" htmlFor="internal-order-notes">
{notes ? "Edit internal notes" : "Add a note for your teammates"}
</label>
<textarea
id="internal-order-notes"
Review

Should add a maxLength to the textarea for UX

Should add a `maxLength` to the textarea for UX
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
aria-describedby="internal-order-notes-help"
placeholder="Add a note for your teammates..."
rows={3}
value={draft}
disabled={isSaving}
onChange={(event) => setDraft(event.target.value)}
/>
<div className="mt-2 flex items-center justify-between gap-3">
<p id="internal-order-notes-help" className="text-xs text-slate-600">
{length}/1000 characters. Saving replaces the current notes.
</p>
<button
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
type="submit"
disabled={isSaving || !draft.trim() || length > 1000}
>
{isSaving ? "Saving..." : notes ? "Save notes" : "Add note"}
</button>
</div>
</form>
</section>
);
}
+8 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { Address, AuditEvent, OrderDetail } from "@orderops/shared"; import type { Address, AuditEvent, OrderDetail } from "@orderops/shared";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Toast } from "../ui/toast"; import { Toast } from "../ui/toast";
import { cancelOrder, getOrder, listAuditEvents, updateOrderItemQuantity, updateShippingAddress } from "./order-api"; import { cancelOrder, getOrder, listAuditEvents, updateOrderItemQuantity, updateOrderNotes, updateShippingAddress } from "./order-api";
import { OrderDetailPage } from "./order-detail-page"; import { OrderDetailPage } from "./order-detail-page";
interface ToastState { interface ToastState {
@@ -55,7 +55,7 @@ export function OrderPage() {
async function runAction(action: () => Promise<OrderDetail>, successMessage: string) { async function runAction(action: () => Promise<OrderDetail>, successMessage: string) {
if (!orderId) { if (!orderId) {
return; return false;
} }
try { try {
@@ -64,11 +64,13 @@ export function OrderPage() {
setOrder(nextOrder); setOrder(nextOrder);
await refreshOrder(orderId); await refreshOrder(orderId);
setToast({ message: successMessage, variant: "success" }); setToast({ message: successMessage, variant: "success" });
return true;
Review

A little unsure why these needed to be added.

A little unsure why these needed to be added.
} catch (caught) { } catch (caught) {
setToast({ setToast({
message: caught instanceof Error ? caught.message : "Request failed.", message: caught instanceof Error ? caught.message : "Request failed.",
variant: "error" variant: "error"
}); });
return false;
} }
} }
@@ -84,9 +86,10 @@ export function OrderPage() {
<OrderDetailPage <OrderDetailPage
order={order} order={order}
auditEvents={auditEvents} auditEvents={auditEvents}
onUpdateQuantity={(itemId, quantity) => runAction(() => updateOrderItemQuantity(order.id, itemId, quantity), "Quantity saved.")} onUpdateQuantity={async (itemId, quantity) => { await runAction(() => updateOrderItemQuantity(order.id, itemId, quantity), "Quantity saved."); }}
onSaveAddress={(address: Address) => runAction(() => updateShippingAddress(order.id, address), "Shipping address saved.")} onSaveAddress={async (address: Address) => { await runAction(() => updateShippingAddress(order.id, address), "Shipping address saved."); }}
onCancel={() => runAction(() => cancelOrder(order.id), "Order cancelled.")} onCancel={async () => { await runAction(() => cancelOrder(order.id), "Order cancelled."); }}
onSaveNotes={(notes) => runAction(() => updateOrderNotes(order.id, notes), "Internal notes saved.")}
/> />
) : null} ) : null}
</div> </div>
+1
View File
@@ -17,6 +17,7 @@ export interface OrderDetail {
id: string; id: string;
orderNumber: string; orderNumber: string;
status: OrderStatus; status: OrderStatus;
notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
customer: CustomerSummary; customer: CustomerSummary;