Compare commits
3 Commits
5d8ef96d4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 435aa88e29 | |||
| 7a5321c097 | |||
| 2b05ff8b02 |
@@ -274,9 +274,10 @@ async def download_nvs(
|
|||||||
sn: str,
|
sn: str,
|
||||||
hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"),
|
hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"),
|
||||||
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
|
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
|
||||||
|
nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"),
|
||||||
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
):
|
):
|
||||||
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override)
|
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy"))
|
||||||
await audit.log_action(
|
await audit.log_action(
|
||||||
admin_user=user.email,
|
admin_user=user.email,
|
||||||
action="device_flashed",
|
action="device_flashed",
|
||||||
|
|||||||
@@ -197,12 +197,13 @@ def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None =
|
|||||||
return _doc_to_inventory_item(doc_ref.get())
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None) -> bytes:
|
def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None, legacy: bool = False) -> bytes:
|
||||||
item = get_device_by_sn(sn)
|
item = get_device_by_sn(sn)
|
||||||
return generate_nvs_binary(
|
return generate_nvs_binary(
|
||||||
serial_number=item.serial_number,
|
serial_number=item.serial_number,
|
||||||
hw_family=hw_type_override if hw_type_override else item.hw_type,
|
hw_family=hw_type_override if hw_type_override else item.hw_type,
|
||||||
hw_revision=hw_revision_override if hw_revision_override else item.hw_version,
|
hw_revision=hw_revision_override if hw_revision_override else item.hw_version,
|
||||||
|
legacy=legacy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function ReadField({ label, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = ["Overview", "Support", "Financials", "Orders", "Quotations", "Communication", "Files & Media", "Devices"];
|
const TABS = ["Overview", "Communication", "Quotations", "Orders", "Finance", "Files & Media", "Devices", "Support"];
|
||||||
|
|
||||||
const LANGUAGE_LABELS = {
|
const LANGUAGE_LABELS = {
|
||||||
el: "Greek",
|
el: "Greek",
|
||||||
@@ -457,7 +457,9 @@ export default function CustomerDetail() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState(() => {
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
const tab = searchParams.get("tab");
|
const tab = searchParams.get("tab");
|
||||||
const TABS = ["Overview", "Support", "Financials", "Orders", "Quotations", "Communication", "Files & Media", "Devices"];
|
const TABS = ["Overview", "Communication", "Quotations", "Orders", "Finance", "Files & Media", "Devices", "Support"];
|
||||||
|
// Also accept old tab names for backwards compat
|
||||||
|
if (tab === "Financials") return "Finance";
|
||||||
return TABS.includes(tab) ? tab : "Overview";
|
return TABS.includes(tab) ? tab : "Overview";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -682,7 +684,7 @@ export default function CustomerDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); }
|
if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); }
|
||||||
if (activeTab === "Support") { /* customer data already loaded */ }
|
if (activeTab === "Support") { /* customer data already loaded */ }
|
||||||
if (activeTab === "Financials") { loadOrders(); }
|
if (activeTab === "Finance") { loadOrders(); }
|
||||||
if (activeTab === "Orders") loadOrders();
|
if (activeTab === "Orders") loadOrders();
|
||||||
if (activeTab === "Communication") loadComms();
|
if (activeTab === "Communication") loadComms();
|
||||||
if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); }
|
if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); }
|
||||||
@@ -1491,8 +1493,8 @@ export default function CustomerDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Financials Tab */}
|
{/* Finance Tab */}
|
||||||
{activeTab === "Financials" && (
|
{activeTab === "Finance" && (
|
||||||
<FinancialsTab
|
<FinancialsTab
|
||||||
customer={customer}
|
customer={customer}
|
||||||
orders={orders}
|
orders={orders}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import api from "../../../api/client";
|
import api from "../../../api/client";
|
||||||
import {
|
import {
|
||||||
ORDER_STATUS_LABELS,
|
ORDER_STATUS_LABELS,
|
||||||
TIMELINE_TYPE_LABELS, OrderStatusChip, fmtDateTime, fmtDateFull,
|
TIMELINE_TYPE_LABELS, OrderStatusChip, fmtDateFull,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" };
|
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" };
|
||||||
@@ -11,6 +11,29 @@ const inputStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--bor
|
|||||||
const STATUSES = Object.entries(ORDER_STATUS_LABELS);
|
const STATUSES = Object.entries(ORDER_STATUS_LABELS);
|
||||||
const TIMELINE_TYPES = Object.entries(TIMELINE_TYPE_LABELS);
|
const TIMELINE_TYPES = Object.entries(TIMELINE_TYPE_LABELS);
|
||||||
|
|
||||||
|
const STATUS_DEFAULT_NOTES = {
|
||||||
|
negotiating: "Just started Negotiating with the customer on a possible new order",
|
||||||
|
awaiting_quotation: "We agreed on what the customer needs, and currently drafting a Quote for them",
|
||||||
|
awaiting_customer_confirmation: "The Quotation has been sent to the Customer. Awaiting their Confirmation",
|
||||||
|
awaiting_fulfilment: "Customer has accepted the Quotation, and no further action is needed from them. First Chance possible we are going to build their device",
|
||||||
|
awaiting_payment: "Customer has accepted the Quotation, but a payment|advance is due before we proceed",
|
||||||
|
manufacturing: "We have begun manufacturing the Customer's Device",
|
||||||
|
shipped: "The order has been Shipped ! Awaiting Customer Feedback",
|
||||||
|
installed: "Customer has informed us that the device has been successfully Installed !",
|
||||||
|
declined: "Customer sadly declined our offer",
|
||||||
|
complete: "Customer has successfully installed, and operated their product. No further action needed ! The order is complete !",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERBOSE_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
const TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
|
||||||
|
function fmtVerboseDateTime(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return `${VERBOSE_DATE_FMT.format(d)}, ${TIME_FMT.format(d).toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete confirm modal ──────────────────────────────────────────────────────
|
// ── Delete confirm modal ──────────────────────────────────────────────────────
|
||||||
function DeleteConfirm({ message, onConfirm, onCancel, deleting }) {
|
function DeleteConfirm({ message, onConfirm, onCancel, deleting }) {
|
||||||
return (
|
return (
|
||||||
@@ -46,8 +69,11 @@ function TimelineEventRow({ event, index, canEdit, onEdit, onDelete }) {
|
|||||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)" }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)" }}>
|
||||||
{TIMELINE_TYPE_LABELS[event.type] || event.type}
|
{TIMELINE_TYPE_LABELS[event.type] || event.type}
|
||||||
</div>
|
</div>
|
||||||
{event.note && <div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2, whiteSpace: "pre-wrap" }}>{event.note}</div>}
|
{event.note && <div style={{ fontSize: 12, color: "var(--text-primary)", marginTop: 3, whiteSpace: "pre-wrap" }}>{event.note}</div>}
|
||||||
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>{fmtDateTime(event.date)} · {event.updated_by}</div>
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 3 }}>
|
||||||
|
{fmtVerboseDateTime(event.date)}
|
||||||
|
{event.updated_by && <span style={{ opacity: 0.55 }}> · {event.updated_by}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && hovered && (
|
{canEdit && hovered && (
|
||||||
<div style={{ display: "flex", gap: 4, flexShrink: 0, alignSelf: "flex-start" }}>
|
<div style={{ display: "flex", gap: 4, flexShrink: 0, alignSelf: "flex-start" }}>
|
||||||
@@ -146,7 +172,6 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
|
|||||||
);
|
);
|
||||||
if (!existingMatch) {
|
if (!existingMatch) {
|
||||||
const statusToType = {
|
const statusToType = {
|
||||||
// "negotiating" as the first archived entry → "Started Negotiations"
|
|
||||||
negotiating: "negotiations_started",
|
negotiating: "negotiations_started",
|
||||||
awaiting_quotation: "quote_request",
|
awaiting_quotation: "quote_request",
|
||||||
awaiting_customer_confirmation: "quote_sent",
|
awaiting_customer_confirmation: "quote_sent",
|
||||||
@@ -167,13 +192,13 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
|
|||||||
archived_status: order.status,
|
archived_status: order.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Step 2: update to new status (and optionally title)
|
// Step 2: update to new status — if note is left empty, save as empty (not the old note)
|
||||||
await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
|
await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
|
||||||
status: statusUpdateForm.newStatus,
|
status: statusUpdateForm.newStatus,
|
||||||
title: statusUpdateForm.title || order.title || null,
|
title: statusUpdateForm.title || order.title || null,
|
||||||
status_updated_date: new Date(statusUpdateForm.datetime).toISOString(),
|
status_updated_date: new Date(statusUpdateForm.datetime).toISOString(),
|
||||||
status_updated_by: user?.name || "Staff",
|
status_updated_by: user?.name || "Staff",
|
||||||
notes: statusUpdateForm.note || order.notes || null,
|
notes: statusUpdateForm.note,
|
||||||
});
|
});
|
||||||
setShowStatusUpdate(false);
|
setShowStatusUpdate(false);
|
||||||
setStatusUpdateForm({ newStatus: order.status || "negotiating", title: order.title || "", note: "", datetime: new Date().toISOString().slice(0, 16) });
|
setStatusUpdateForm({ newStatus: order.status || "negotiating", title: order.title || "", note: "", datetime: new Date().toISOString().slice(0, 16) });
|
||||||
@@ -262,7 +287,21 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
|
|||||||
{/* Update Status button — neutral style */}
|
{/* Update Status button — neutral style */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); setShowStatusUpdate((v) => !v); setShowEditForm(false); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowStatusUpdate((v) => {
|
||||||
|
if (!v) {
|
||||||
|
// Reset datetime to NOW and pre-fill default note each time panel opens
|
||||||
|
setStatusUpdateForm((f) => ({
|
||||||
|
...f,
|
||||||
|
datetime: new Date().toISOString().slice(0, 16),
|
||||||
|
note: STATUS_DEFAULT_NOTES[f.newStatus] || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return !v;
|
||||||
|
});
|
||||||
|
setShowEditForm(false);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12, padding: "4px 12px", borderRadius: 6,
|
fontSize: 12, padding: "4px 12px", borderRadius: 6,
|
||||||
border: "1px solid var(--border-primary)",
|
border: "1px solid var(--border-primary)",
|
||||||
@@ -306,7 +345,7 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ ...labelStyle, marginBottom: 4 }}>New Status</div>
|
<div style={{ ...labelStyle, marginBottom: 4 }}>New Status</div>
|
||||||
<select value={statusUpdateForm.newStatus} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, newStatus: e.target.value }))} style={inputStyle}>
|
<select value={statusUpdateForm.newStatus} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, newStatus: e.target.value, note: STATUS_DEFAULT_NOTES[e.target.value] || "" }))} style={inputStyle}>
|
||||||
{STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,6 +547,14 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── New Order form ────────────────────────────────────────────────────────────
|
// ── New Order form ────────────────────────────────────────────────────────────
|
||||||
|
async function setCustomerActive(customerId) {
|
||||||
|
try {
|
||||||
|
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "active" });
|
||||||
|
} catch {
|
||||||
|
// Non-critical — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumber }) {
|
function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumber }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
order_number: suggestedOrderNumber || "",
|
order_number: suggestedOrderNumber || "",
|
||||||
@@ -531,6 +578,7 @@ function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumbe
|
|||||||
status_updated_date: new Date(form.date).toISOString(),
|
status_updated_date: new Date(form.date).toISOString(),
|
||||||
status_updated_by: user?.name || "Staff",
|
status_updated_by: user?.name || "Staff",
|
||||||
});
|
});
|
||||||
|
await setCustomerActive(customerId);
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import api from "../../../api/client";
|
|||||||
import { CommTypeIconBadge, CommDirectionIcon } from "../../components/CommIcons";
|
import { CommTypeIconBadge, CommDirectionIcon } from "../../components/CommIcons";
|
||||||
import { REL_STATUS_LABELS, REL_STATUS_STYLES, OrderStatusChip, fmtDate } from "./shared";
|
import { REL_STATUS_LABELS, REL_STATUS_STYLES, OrderStatusChip, fmtDate } from "./shared";
|
||||||
|
|
||||||
|
import clientIcon from "../../../assets/customer-status/client.svg?raw";
|
||||||
|
import inactiveIcon from "../../../assets/customer-status/inactive.svg?raw";
|
||||||
|
import churnedIcon from "../../../assets/customer-status/churned.svg?raw";
|
||||||
|
import exclamationIcon from "../../../assets/customer-status/exclamation.svg?raw";
|
||||||
|
import wrenchIcon from "../../../assets/customer-status/wrench.svg?raw";
|
||||||
|
import orderIcon from "../../../assets/customer-status/order.svg?raw";
|
||||||
|
|
||||||
const LANGUAGE_LABELS = {
|
const LANGUAGE_LABELS = {
|
||||||
af:"Afrikaans",sq:"Albanian",am:"Amharic",ar:"Arabic",hy:"Armenian",az:"Azerbaijani",
|
af:"Afrikaans",sq:"Albanian",am:"Amharic",ar:"Arabic",hy:"Armenian",az:"Azerbaijani",
|
||||||
eu:"Basque",be:"Belarusian",bn:"Bengali",bs:"Bosnian",bg:"Bulgarian",ca:"Catalan",
|
eu:"Basque",be:"Belarusian",bn:"Bengali",bs:"Bosnian",bg:"Bulgarian",ca:"Catalan",
|
||||||
@@ -56,8 +63,26 @@ function TagsField({ tags }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verbose description per relationship status
|
||||||
|
const REL_STATUS_DESCRIPTIONS = {
|
||||||
|
lead: "This contact is a potential new lead. No active engagement yet.",
|
||||||
|
prospect: "Actively engaged — exploring possibilities before a formal order.",
|
||||||
|
active: "Active customer with ongoing or recent commercial activity.",
|
||||||
|
inactive: "No recent engagement. May need a follow-up to re-activate.",
|
||||||
|
churned: "Customer has disengaged. Orders declined or no activity for a long period.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Icon per relationship status (same logic as CustomerList resolveStatusIcon base cases)
|
||||||
|
const REL_STATUS_ICONS = {
|
||||||
|
lead: clientIcon,
|
||||||
|
prospect: clientIcon,
|
||||||
|
active: clientIcon,
|
||||||
|
inactive: inactiveIcon,
|
||||||
|
churned: churnedIcon,
|
||||||
|
};
|
||||||
|
|
||||||
// Status badge: shows current status + gear icon to open inline change dropdown
|
// Status badge: shows current status + gear icon to open inline change dropdown
|
||||||
function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
|
function RelStatusSelector({ customer, onUpdated, canEdit }) {
|
||||||
const statuses = ["lead", "prospect", "active", "inactive", "churned"];
|
const statuses = ["lead", "prospect", "active", "inactive", "churned"];
|
||||||
const current = customer.relationship_status || "lead";
|
const current = customer.relationship_status || "lead";
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -81,29 +106,45 @@ function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const st = REL_STATUS_STYLES[current] || REL_STATUS_STYLES.lead;
|
const st = REL_STATUS_STYLES[current] || REL_STATUS_STYLES.lead;
|
||||||
|
const icon = REL_STATUS_ICONS[current];
|
||||||
|
const description = REL_STATUS_DESCRIPTIONS[current] || "";
|
||||||
|
|
||||||
|
ensureShimmer();
|
||||||
|
const shimmerGradient = `linear-gradient(120deg, ${st.border}44 0%, ${st.border}cc 40%, ${st.border}ff 50%, ${st.border}cc 60%, ${st.border}44 100%)`;
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative", display: "flex" }} ref={ref}>
|
<div style={{ position: "relative", flex: 1, minWidth: 0 }} ref={ref}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => canEdit && setOpen((v) => !v)}
|
onClick={() => canEdit && setOpen((v) => !v)}
|
||||||
|
className="crm-shimmer-card"
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 8,
|
"--crm-shimmer-gradient": shimmerGradient,
|
||||||
padding: "7px 14px", borderRadius: 8,
|
display: "flex", alignItems: "center", gap: 14,
|
||||||
border: `1px solid ${st.border}`,
|
padding: "14px 18px", borderRadius: 10,
|
||||||
|
border: `1.5px solid ${st.border}`,
|
||||||
backgroundColor: st.bg,
|
backgroundColor: st.bg,
|
||||||
color: st.color,
|
color: st.color,
|
||||||
cursor: canEdit ? "pointer" : "default",
|
cursor: canEdit ? "pointer" : "default",
|
||||||
fontSize: 13, fontWeight: 700,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
boxShadow: `0 0 16px ${st.border}33`,
|
||||||
|
transition: "box-shadow 0.2s",
|
||||||
|
position: "relative", zIndex: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, opacity: 0.65, textTransform: "uppercase", letterSpacing: "0.06em" }}>Status</span>
|
{/* Icon */}
|
||||||
<span style={{ width: 1, height: 14, backgroundColor: "currentColor", opacity: 0.3, flexShrink: 0 }} />
|
{icon && renderMaskedIconOv(icon, st.color, 28)}
|
||||||
<span>{REL_STATUS_LABELS[current] || current}</span>
|
{/* Text block */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em" }}>Customer Status</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 1, letterSpacing: "0.01em" }}>{REL_STATUS_LABELS[current] || current}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 400, opacity: 0.7, marginTop: 3, lineHeight: 1.4 }}>{description}</div>
|
||||||
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7, flexShrink: 0 }}>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5, flexShrink: 0, alignSelf: "flex-start", marginTop: 2 }}>
|
||||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
@@ -139,67 +180,112 @@ function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const STAT_CARD_STYLES = {
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
issue: { bg: "var(--crm-issue-active-bg,rgba(224,53,53,0.12))", color: "var(--crm-issue-active-text)", border: "rgba(224,53,53,0.35)" },
|
||||||
{statuses.map((s) => {
|
support: { bg: "var(--crm-support-active-bg,rgba(247,103,7,0.12))", color: "var(--crm-support-active-text)", border: "rgba(247,103,7,0.35)" },
|
||||||
const sst = REL_STATUS_STYLES[s] || {};
|
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "rgba(59,130,246,0.35)" },
|
||||||
const isActive = s === current;
|
};
|
||||||
return (
|
|
||||||
<button
|
const STAT_ICONS = {
|
||||||
key={s}
|
issue: exclamationIcon,
|
||||||
type="button"
|
support: wrenchIcon,
|
||||||
onClick={() => handleClick(s)}
|
order: orderIcon,
|
||||||
disabled={!canEdit}
|
};
|
||||||
style={{
|
|
||||||
padding: "5px 14px",
|
const STAT_LABELS = {
|
||||||
borderRadius: 20,
|
issue: "Open Issues",
|
||||||
fontSize: 12,
|
support: "Support Assists",
|
||||||
fontWeight: 600,
|
order: "Open Orders",
|
||||||
cursor: canEdit ? "pointer" : "default",
|
};
|
||||||
border: `1px solid ${isActive ? sst.border : "var(--border-primary)"}`,
|
|
||||||
backgroundColor: isActive ? sst.bg : "transparent",
|
// Shared shimmer keyframes injected once
|
||||||
color: isActive ? sst.color : "var(--text-muted)",
|
let _shimmerInjected = false;
|
||||||
boxShadow: isActive ? `0 0 8px ${sst.bg}` : "none",
|
function ensureShimmer() {
|
||||||
transition: "all 0.15s ease",
|
if (_shimmerInjected) return;
|
||||||
}}
|
_shimmerInjected = true;
|
||||||
>
|
const style = document.createElement("style");
|
||||||
{REL_STATUS_LABELS[s]}
|
style.textContent = `
|
||||||
</button>
|
@keyframes crm-border-shimmer {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
.crm-shimmer-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.crm-shimmer-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1.5px;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 1.5px;
|
||||||
|
background: var(--crm-shimmer-gradient);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: crm-border-shimmer 3s ease infinite;
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMaskedIconOv(icon, color, size = 16) {
|
||||||
|
const svgMarkup = icon
|
||||||
|
.replace(/<\?xml[\s\S]*?\?>/gi, "")
|
||||||
|
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
.replace(
|
||||||
|
/<svg\b([^>]*)>/i,
|
||||||
|
`<svg$1 width="${size}" height="${size}" style="display:block;width:${size}px;height:${size}px;color:${color};fill:currentColor;">`,
|
||||||
);
|
);
|
||||||
})}
|
return (
|
||||||
</div>
|
<span style={{ width: size, height: size, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: svgMarkup }} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// bg/color/border per chip type
|
// Stat card — mirrors the status hero layout exactly so all cards share the same height
|
||||||
const CHIP_STYLES = {
|
function StatCard({ count, onClick, type }) {
|
||||||
issue: { bg: "var(--crm-issue-active-bg,rgba(224,53,53,0.12))", color: "var(--crm-issue-active-text)", border: "var(--crm-issue-active-text)" },
|
ensureShimmer();
|
||||||
support: { bg: "var(--crm-support-active-bg,rgba(247,103,7,0.12))", color: "var(--crm-support-active-text)", border: "var(--crm-support-active-text)" },
|
const s = STAT_CARD_STYLES[type] || {};
|
||||||
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "var(--badge-blue-text)" },
|
const icon = STAT_ICONS[type];
|
||||||
};
|
const label = STAT_LABELS[type] || type;
|
||||||
|
const shimmerGradient = `linear-gradient(120deg, ${s.border}44 0%, ${s.border}cc 40%, ${s.border}ff 50%, ${s.border}cc 60%, ${s.border}44 100%)`;
|
||||||
|
|
||||||
function StatChip({ count, label, onClick, type }) {
|
|
||||||
const s = CHIP_STYLES[type] || {};
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
className="crm-shimmer-card"
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 7,
|
"--crm-shimmer-gradient": shimmerGradient,
|
||||||
padding: "7px 14px", borderRadius: 8, fontSize: 13,
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
border: `1px solid ${s.border || "var(--border-primary)"}`,
|
padding: "14px 18px",
|
||||||
backgroundColor: s.bg || "var(--bg-primary)",
|
border: `1.5px solid ${s.border}`,
|
||||||
color: s.color || "var(--text-secondary)",
|
borderRadius: 10,
|
||||||
|
backgroundColor: s.bg,
|
||||||
|
color: s.color,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
transition: "opacity 0.15s",
|
transition: "box-shadow 0.2s",
|
||||||
fontWeight: 600,
|
boxShadow: `0 0 14px ${s.border}33`,
|
||||||
|
position: "relative", zIndex: 0,
|
||||||
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"}
|
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = `0 0 22px ${s.border}55`; }}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"}
|
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = `0 0 14px ${s.border}33`; }}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 13, fontWeight: 700 }}>{count}</span>
|
{icon && renderMaskedIconOv(icon, s.color, 28)}
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", opacity: 0.8 }}>{label}</span>
|
<div>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em" }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, lineHeight: 1.1, marginTop: 1 }}>{count}</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -403,20 +489,17 @@ export default function OverviewTab({
|
|||||||
<div>
|
<div>
|
||||||
{/* Main hero info card */}
|
{/* Main hero info card */}
|
||||||
<div className="ui-section-card mb-4">
|
<div className="ui-section-card mb-4">
|
||||||
{/* Row 1: Status badge + stat chips */}
|
{/* Hero row: status (flex-grow) + stat cards (shrink-to-fit), all on one line */}
|
||||||
<div style={{ display: "flex", alignItems: "stretch", gap: 18, flexWrap: "wrap", marginBottom: 30 }}>
|
<div style={{ display: "flex", alignItems: "stretch", gap: 8, flexWrap: "wrap", marginBottom: 20 }}>
|
||||||
{/* Status badge — includes inline change dropdown via gear */}
|
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} />
|
||||||
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} compact />
|
|
||||||
|
|
||||||
{/* Stat chips — only shown when count > 0 */}
|
|
||||||
{openIssues > 0 && (
|
{openIssues > 0 && (
|
||||||
<StatChip count={openIssues} label={`Issue${openIssues !== 1 ? "s" : ""}`} onClick={() => onTabChange("Support")} type="issue" />
|
<StatCard count={openIssues} onClick={() => onTabChange("Support")} type="issue" />
|
||||||
)}
|
)}
|
||||||
{supportInquiries > 0 && (
|
{supportInquiries > 0 && (
|
||||||
<StatChip count={supportInquiries} label={`Support${supportInquiries !== 1 ? " Tickets" : " Ticket"}`} onClick={() => onTabChange("Support")} type="support" />
|
<StatCard count={supportInquiries} onClick={() => onTabChange("Support")} type="support" />
|
||||||
)}
|
)}
|
||||||
{openOrders > 0 && (
|
{openOrders > 0 && (
|
||||||
<StatChip count={openOrders} label={`Order${openOrders !== 1 ? "s" : ""}`} onClick={() => onTabChange("Orders")} type="order" />
|
<StatCard count={openOrders} onClick={() => onTabChange("Orders")} type="order" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export function InitNegotiationsModal({ customerId, user, onClose, onSuccess })
|
|||||||
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
|
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
|
||||||
created_by: user?.name || "Staff",
|
created_by: user?.name || "Staff",
|
||||||
});
|
});
|
||||||
|
// Auto-set customer to Active when a new order is initiated
|
||||||
|
try {
|
||||||
|
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "active" });
|
||||||
|
} catch { /* non-critical */ }
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -777,8 +777,8 @@ export default function CustomerList() {
|
|||||||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>;
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>;
|
||||||
}
|
}
|
||||||
case "location": {
|
case "location": {
|
||||||
const cityCountry = [loc.city, loc.country].filter(Boolean).join(", ");
|
const locationDisplay = loc.city || loc.country || "";
|
||||||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{cityCountry || "—"}</td>;
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{locationDisplay || "—"}</td>;
|
||||||
}
|
}
|
||||||
case "email":
|
case "email":
|
||||||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>;
|
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>;
|
||||||
|
|||||||
@@ -373,6 +373,161 @@ function NoteModal({ hwType, currentNote, onClose, onSaved }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Add Bespoke Board Modal ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AddBespokeModal({ existingUids, onClose, onSaved }) {
|
||||||
|
const [uid, setUid] = useState("");
|
||||||
|
const [bootFile, setBootFile] = useState(null);
|
||||||
|
const [partFile, setPartFile] = useState(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const bootRef = useRef(null);
|
||||||
|
const partRef = useRef(null);
|
||||||
|
|
||||||
|
const uidSlug = uid.trim().toLowerCase().replace(/\s+/g, "-");
|
||||||
|
const isValidUid = /^[a-z0-9][a-z0-9._-]{0,126}$/.test(uidSlug);
|
||||||
|
const isKnown = KNOWN_BOARD_TYPES.some((b) => b.value === uidSlug);
|
||||||
|
const isDuplicate = existingUids.includes(uidSlug);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isValidUid) { setError("UID must be lowercase alphanumeric with hyphens/dots only."); return; }
|
||||||
|
if (isKnown) { setError("That name matches a standard board type. Use a unique bespoke UID."); return; }
|
||||||
|
if (isDuplicate) { setError("A bespoke board with that UID already exists."); return; }
|
||||||
|
if (!bootFile && !partFile) { setError("Upload at least one file."); return; }
|
||||||
|
setError(""); setUploading(true);
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
try {
|
||||||
|
for (const [asset, file] of [["bootloader.bin", bootFile], ["partitions.bin", partFile]]) {
|
||||||
|
if (!file) continue;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch(`/api/manufacturing/flash-assets/${uidSlug}/${asset}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `Failed to upload ${asset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilePicker = ({ label, file, setFile, inputRef }) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>{label}</label>
|
||||||
|
<div
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f && f.name.endsWith(".bin")) setFile(f);
|
||||||
|
else if (f) setError("Only .bin files are accepted.");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: "0.5rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`,
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: file ? "var(--badge-blue-bg)" : "var(--bg-input)",
|
||||||
|
cursor: "pointer", transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input ref={inputRef} type="file" accept=".bin" style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files[0];
|
||||||
|
if (f && !f.name.endsWith(".bin")) { setError("Only .bin files are accepted."); return; }
|
||||||
|
setFile(f || null); setError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: file ? "var(--btn-primary)" : "var(--text-muted)", flexShrink: 0 }}>
|
||||||
|
{file ? <IconCheck /> : <IconUpload />}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono" style={{ fontSize: "0.72rem", color: file ? "var(--badge-blue-text)" : "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{file ? `${file.name} (${formatBytes(file.size)})` : `click or drop ${label}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border w-full mx-4 flex flex-col"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "480px" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 pt-4 pb-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Add Bespoke Board</h3>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Create a new bespoke flash asset set with a unique UID.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="px-5 py-4" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Bespoke UID <span style={{ color: "var(--danger-text)" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uid}
|
||||||
|
onChange={(e) => { setUid(e.target.value); setError(""); }}
|
||||||
|
placeholder="e.g. client-athens-v1"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 rounded-md text-sm border font-mono"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
|
/>
|
||||||
|
{uidSlug && (
|
||||||
|
<p className="text-xs mt-1 font-mono" style={{ color: isValidUid && !isKnown && !isDuplicate ? "var(--text-muted)" : "var(--danger-text)" }}>
|
||||||
|
{isDuplicate ? "Already exists" : isKnown ? "Matches a standard board type" : !isValidUid ? "Invalid format" : `→ ${uidSlug}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FilePicker label="bootloader.bin" file={bootFile} setFile={setBootFile} inputRef={bootRef} />
|
||||||
|
<FilePicker label="partitions.bin" file={partFile} setFile={setPartFile} inputRef={partRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={uploading || !uidSlug}
|
||||||
|
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading…" : "Add Board"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete Confirm Modal ──────────────────────────────────────────────────────
|
// ── Delete Confirm Modal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
|
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
|
||||||
@@ -551,6 +706,8 @@ export default function FlashAssetManager({ onClose }) {
|
|||||||
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
|
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
|
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
|
||||||
const [noteTarget, setNoteTarget] = useState(null); // entry
|
const [noteTarget, setNoteTarget] = useState(null); // entry
|
||||||
|
const [showAddBespoke, setShowAddBespoke] = useState(false);
|
||||||
|
const canAdd = hasPermission("manufacturing", "add");
|
||||||
|
|
||||||
const fetchAssets = useCallback(async () => {
|
const fetchAssets = useCallback(async () => {
|
||||||
setLoading(true); setError("");
|
setLoading(true); setError("");
|
||||||
@@ -653,6 +810,15 @@ export default function FlashAssetManager({ onClose }) {
|
|||||||
<IconRefresh />
|
<IconRefresh />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
|
{canAdd && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddBespoke(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
style={{ backgroundColor: "#2a0a3a", color: "#a855f7", border: "1px solid #a855f7" }}
|
||||||
|
>
|
||||||
|
+ Bespoke Board
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-lg leading-none hover:opacity-70 cursor-pointer"
|
className="text-lg leading-none hover:opacity-70 cursor-pointer"
|
||||||
@@ -788,6 +954,14 @@ export default function FlashAssetManager({ onClose }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAddBespoke && (
|
||||||
|
<AddBespokeModal
|
||||||
|
existingUids={assets.filter((e) => e.is_bespoke).map((e) => e.hw_type)}
|
||||||
|
onClose={() => setShowAddBespoke(false)}
|
||||||
|
onSaved={() => { setShowAddBespoke(false); fetchAssets(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -851,6 +851,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
const [serial, setSerial] = useState([]);
|
const [serial, setSerial] = useState([]);
|
||||||
const [serialAutoScroll, setSerialAutoScroll] = useState(true);
|
const [serialAutoScroll, setSerialAutoScroll] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [nvsSchema, setNvsSchema] = useState("new");
|
||||||
|
|
||||||
const loaderRef = useRef(null);
|
const loaderRef = useRef(null);
|
||||||
const portRef = useRef(null);
|
const portRef = useRef(null);
|
||||||
@@ -993,9 +994,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
const partUrl = bespokeOverride
|
const partUrl = bespokeOverride
|
||||||
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
|
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
|
||||||
: `/api/manufacturing/devices/${sn}/partitions.bin`;
|
: `/api/manufacturing/devices/${sn}/partitions.bin`;
|
||||||
|
const nvsSchemaParam = nvsSchema === "legacy" ? "&nvs_schema=legacy" : "";
|
||||||
const nvsUrl = bespokeOverride
|
const nvsUrl = bespokeOverride
|
||||||
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0`
|
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0${nvsSchemaParam}`
|
||||||
: `/api/manufacturing/devices/${sn}/nvs.bin`;
|
: `/api/manufacturing/devices/${sn}/nvs.bin${nvsSchema === "legacy" ? "?nvs_schema=legacy" : ""}`;
|
||||||
|
|
||||||
appendLog("Fetching bootloader binary…");
|
appendLog("Fetching bootloader binary…");
|
||||||
const blBuffer = await fetchBinary(blUrl);
|
const blBuffer = await fetchBinary(blUrl);
|
||||||
@@ -1232,6 +1234,21 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{portConnected && !done && (
|
{portConnected && !done && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={nvsSchema}
|
||||||
|
onChange={(e) => setNvsSchema(e.target.value)}
|
||||||
|
disabled={flashing}
|
||||||
|
className="px-3 py-2 text-sm rounded-md border cursor-pointer font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="new">NVS: Current Gen</option>
|
||||||
|
<option value="legacy">NVS: Legacy Gen</option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartFlash}
|
onClick={handleStartFlash}
|
||||||
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
@@ -1242,6 +1259,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
</svg>
|
</svg>
|
||||||
Start Flashing
|
Start Flashing
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user