feat(orders): add employee only internal order notes #14
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|
|||||||
|
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
@@ -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 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
derrick
commented
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"
|
||||||
|
derrick
commented
Should add a 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
derrick
commented
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user
It would be better to not hard code values here.