This commit is contained in:
2026-03-15 19:40:35 -05:00
parent 275c73b584
commit dcac4f135d
17 changed files with 659 additions and 318 deletions

View File

@@ -8,10 +8,17 @@ import type {
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
type ProjectPendingConfirmation =
| { kind: "change-customer"; customerId: string; customerName: string }
| { kind: "unlink-quote" }
| { kind: "unlink-order" }
| { kind: "unlink-shipment" };
export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const { token, user } = useAuth();
const navigate = useNavigate();
@@ -34,6 +41,7 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
useEffect(() => {
if (!token) {
@@ -103,6 +111,43 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
}));
}
function hasLinkedCommercialRecords() {
return Boolean(form.salesQuoteId || form.salesOrderId || form.shipmentId);
}
function applyCustomerSelection(customerId: string, customerName: string) {
updateField("customerId", customerId);
setCustomerSearchTerm(customerName);
setCustomerPickerOpen(false);
}
function requestCustomerSelection(customerId: string, customerName: string) {
if (form.customerId && form.customerId !== customerId && hasLinkedCommercialRecords()) {
setPendingConfirmation({ kind: "change-customer", customerId, customerName });
return;
}
applyCustomerSelection(customerId, customerName);
}
function unlinkQuote() {
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}
function unlinkOrder() {
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}
function unlinkShipment() {
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}
function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
@@ -158,13 +203,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false);
@@ -187,9 +231,7 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
.map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(false);
requestCustomerSelection(customer.id, customer.name);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
@@ -274,13 +316,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quote</span>
<div className="relative">
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
updateField("salesQuoteId", null);
setQuotePickerOpen(true);
}}
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
setQuotePickerOpen(true);
}}
onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false);
@@ -293,9 +334,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
if (form.salesQuoteId) {
setPendingConfirmation({ kind: "unlink-quote" });
} else {
unlinkQuote();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked quote</div>
</button>
@@ -326,13 +369,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", null);
setOrderPickerOpen(true);
}}
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false);
@@ -345,9 +387,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
if (form.salesOrderId) {
setPendingConfirmation({ kind: "unlink-order" });
} else {
unlinkOrder();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked sales order</div>
</button>
@@ -378,13 +422,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<div className="relative">
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
updateField("shipmentId", null);
setShipmentPickerOpen(true);
}}
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
setShipmentPickerOpen(true);
}}
onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false);
@@ -397,9 +440,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
if (form.shipmentId) {
setPendingConfirmation({ kind: "unlink-shipment" });
} else {
unlinkShipment();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked shipment</div>
</button>
@@ -439,6 +484,60 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={
pendingConfirmation?.kind === "change-customer"
? "Change project customer"
: pendingConfirmation?.kind === "unlink-quote"
? "Remove linked quote"
: pendingConfirmation?.kind === "unlink-order"
? "Remove linked sales order"
: "Remove linked shipment"
}
description={
pendingConfirmation?.kind === "change-customer"
? `Switch this project to ${pendingConfirmation.customerName}. Existing quote, sales order, and shipment links will be cleared.`
: pendingConfirmation?.kind === "unlink-quote"
? "Remove the currently linked quote from this project draft."
: pendingConfirmation?.kind === "unlink-order"
? "Remove the currently linked sales order from this project draft."
: "Remove the currently linked shipment from this project draft."
}
impact={
pendingConfirmation?.kind === "change-customer"
? "Commercial and delivery linkage tied to the previous customer will be cleared immediately from the draft."
: "The project will no longer point to that related record after you save this edit."
}
recovery={
pendingConfirmation?.kind === "change-customer"
? "Re-link the correct quote, order, and shipment before saving if the customer change was accidental."
: "Pick the related record again before saving if this unlink was a mistake."
}
confirmLabel={
pendingConfirmation?.kind === "change-customer"
? "Change customer"
: "Remove link"
}
onClose={() => setPendingConfirmation(null)}
onConfirm={() => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "change-customer") {
applyCustomerSelection(pendingConfirmation.customerId, pendingConfirmation.customerName);
} else if (pendingConfirmation.kind === "unlink-quote") {
unlinkQuote();
} else if (pendingConfirmation.kind === "unlink-order") {
unlinkOrder();
} else if (pendingConfirmation.kind === "unlink-shipment") {
unlinkShipment();
}
setPendingConfirmation(null);
}}
/>
</form>
);
}