INIT frontend
This commit is contained in:
@@ -0,0 +1,834 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Text,
|
||||
Input,
|
||||
Spinner,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogSurface,
|
||||
DialogBody,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuPopover,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
} from "@fluentui/react-components";
|
||||
import { DeleteRegular, SearchRegular, ChevronDownRegular } from "@fluentui/react-icons";
|
||||
import type {
|
||||
GeneratedComponentProps,
|
||||
ccllc_job,
|
||||
ReadableTableRow,
|
||||
QueryTableOptions,
|
||||
} from "./RuntimeTypes";
|
||||
|
||||
/* ──────────────────────────────── DEFAULTS ──────────────────────────────── */
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
"ccllc_jobnumber", "ccllc_jobname", "ccllc_jobaddress", "ccllc_jobtype",
|
||||
"ccllc_flagstatus", "ccllc_jobstatus", "ccllc_jobdivision", "ccllc_estimatorname",
|
||||
"ccllc_officerepresentative", "ccllc_clientcompany", "ccllc_contactperson",
|
||||
"ccllc_contactphonenumber", "ccllc_contactemail", "ccllc_duedate", "ccllc_duedatecounter",
|
||||
"ccllc_duetime", "ccllc_documentsuploaded", "ccllc_quickbookscreated", "ccllc_addedtocalendar",
|
||||
"ccllc_pswiftuploaded", "ccllc_voxercreated", "ccllc_estimatedraft", "ccllc_estimatereviewed",
|
||||
"ccllc_estimateapproved", "ccllc_notes", "ccllc_activestatus", "ccllc_jobquickbookslink",
|
||||
"ccllc_voxerlink", "ccllc_jobcodes", "ccllc_submissiondate", "ccllc_powerappsid",
|
||||
"ccllc_jobfolderlink", "ccllc_taxexemptstatus", "statecode", "statuscode", "ccllc_active",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_FRIENDLY_NAMES: Record<string, string> = {
|
||||
ccllc_jobnumber: "Job Number",
|
||||
ccllc_jobname: "Job Name",
|
||||
statecode: "State",
|
||||
statuscode: "Status",
|
||||
ccllc_active: "Key",
|
||||
ccllc_activestatus: "Active\nStatus",
|
||||
ccllc_addedtocalendar: "Added to\nCalendar",
|
||||
ccllc_clientcompany: "Client\nCompany",
|
||||
ccllc_contactemail: "Contact\nEmail",
|
||||
ccllc_contactperson: "Contact\nPerson",
|
||||
ccllc_contactphonenumber: "Contact\nPhone Number",
|
||||
ccllc_documentsuploaded: "Documents\nUploaded",
|
||||
ccllc_duedate: "Due Date",
|
||||
ccllc_duedatecounter: "Due Date\nCounter",
|
||||
ccllc_duetime: "Due Time",
|
||||
ccllc_estimateapproved: "Estimate\nApproved",
|
||||
ccllc_estimatedraft: "Estimate\nDraft",
|
||||
ccllc_estimatereviewed: "Estimate\nReviewed",
|
||||
ccllc_estimatorname: "Estimator\nName",
|
||||
ccllc_flagstatus: "Flag",
|
||||
ccllc_jobaddress: "Job\nAddress",
|
||||
ccllc_jobcodes: "Job\nCodes",
|
||||
ccllc_jobdivision: "Job\nDivision",
|
||||
ccllc_jobfolderlink: "Job Folder Link",
|
||||
ccllc_jobquickbookslink: "Job QuickBooks\nLink",
|
||||
ccllc_jobstatus: "Job\nStatus",
|
||||
ccllc_jobtype: "Job\nType",
|
||||
ccllc_notes: "Notes",
|
||||
ccllc_officerepresentative: "Office Rep",
|
||||
ccllc_powerappsid: "PowerApps\nID",
|
||||
ccllc_pswiftuploaded: "PSwift\nUploaded",
|
||||
ccllc_quickbookscreated: "QB Created",
|
||||
ccllc_submissiondate: "Submission\nDate",
|
||||
ccllc_taxexemptstatus: "Tax Exempt",
|
||||
ccllc_voxercreated: "Voxer\nCreated",
|
||||
ccllc_voxerlink: "Voxer Link",
|
||||
};
|
||||
|
||||
const DEFAULT_COLUMN_ALIGNMENTS: Record<string, "left" | "center" | "right"> = {
|
||||
ccllc_jobnumber: "center",
|
||||
ccllc_jobname: "left",
|
||||
statecode: "center",
|
||||
statuscode: "center",
|
||||
ccllc_active: "center",
|
||||
ccllc_activestatus: "center",
|
||||
ccllc_addedtocalendar: "center",
|
||||
ccllc_clientcompany: "left",
|
||||
ccllc_contactemail: "left",
|
||||
ccllc_contactperson: "left",
|
||||
ccllc_contactphonenumber: "left",
|
||||
ccllc_documentsuploaded: "center",
|
||||
ccllc_duedate: "center",
|
||||
ccllc_duedatecounter: "center",
|
||||
ccllc_duetime: "center",
|
||||
ccllc_estimateapproved: "center",
|
||||
ccllc_estimatedraft: "center",
|
||||
ccllc_estimatereviewed: "center",
|
||||
ccllc_estimatorname: "left",
|
||||
ccllc_flagstatus: "center",
|
||||
ccllc_jobaddress: "left",
|
||||
ccllc_jobcodes: "left",
|
||||
ccllc_jobdivision: "left",
|
||||
ccllc_jobfolderlink: "left",
|
||||
ccllc_jobquickbookslink: "left",
|
||||
ccllc_jobstatus: "center",
|
||||
ccllc_jobtype: "center",
|
||||
ccllc_notes: "left",
|
||||
ccllc_officerepresentative: "left",
|
||||
ccllc_powerappsid: "center",
|
||||
ccllc_pswiftuploaded: "center",
|
||||
ccllc_quickbookscreated: "center",
|
||||
ccllc_submissiondate: "center",
|
||||
ccllc_taxexemptstatus: "center",
|
||||
ccllc_voxercreated: "center",
|
||||
ccllc_voxerlink: "left",
|
||||
};
|
||||
|
||||
const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
|
||||
ccllc_jobnumber: 100,
|
||||
ccllc_jobname: 280,
|
||||
statecode: 80,
|
||||
statuscode: 80,
|
||||
ccllc_active: 80,
|
||||
ccllc_activestatus: 100,
|
||||
ccllc_addedtocalendar: 120,
|
||||
ccllc_clientcompany: 160,
|
||||
ccllc_contactemail: 180,
|
||||
ccllc_contactperson: 140,
|
||||
ccllc_contactphonenumber: 140,
|
||||
ccllc_documentsuploaded: 120,
|
||||
ccllc_duedate: 110,
|
||||
ccllc_duedatecounter: 110,
|
||||
ccllc_duetime: 100,
|
||||
ccllc_estimateapproved: 120,
|
||||
ccllc_estimatedraft: 120,
|
||||
ccllc_estimatereviewed: 120,
|
||||
ccllc_estimatorname: 140,
|
||||
ccllc_flagstatus: 80,
|
||||
ccllc_jobaddress: 180,
|
||||
ccllc_jobcodes: 120,
|
||||
ccllc_jobdivision: 120,
|
||||
ccllc_jobfolderlink: 160,
|
||||
ccllc_jobquickbookslink: 160,
|
||||
ccllc_jobstatus: 120,
|
||||
ccllc_jobtype: 120,
|
||||
ccllc_notes: 180,
|
||||
ccllc_officerepresentative: 140,
|
||||
ccllc_powerappsid: 120,
|
||||
ccllc_pswiftuploaded: 120,
|
||||
ccllc_quickbookscreated: 120,
|
||||
ccllc_submissiondate: 120,
|
||||
ccllc_taxexemptstatus: 120,
|
||||
ccllc_voxercreated: 120,
|
||||
ccllc_voxerlink: 160,
|
||||
};
|
||||
|
||||
const DEFAULT_COLUMN_VISIBILITY = Object.fromEntries(
|
||||
DEFAULT_COLUMN_ORDER.map(c => [c, true])
|
||||
);
|
||||
|
||||
/* ──────────────────────────────── UTILS ──────────────────────────────── */
|
||||
const getJobColumns = (row?: ReadableTableRow<ccllc_job>): string[] => {
|
||||
const sticky = ["ccllc_jobnumber", "ccllc_jobname"];
|
||||
const ignore = new Set(["ccllc_jobid", "@Odata.Etag", "@odata.etag"]);
|
||||
if (!row) return sticky;
|
||||
return [
|
||||
...sticky,
|
||||
...Object.keys(row).filter(
|
||||
k =>
|
||||
!sticky.includes(k) &&
|
||||
!ignore.has(k) &&
|
||||
!k.endsWith("@OData.Community.Display.V1.FormattedValue") &&
|
||||
typeof row[k as keyof typeof row] !== "function"
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const humanize = (col: string) =>
|
||||
col.replace(/^ccllc_/, "").replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
/* ───────────────────────────── EditableCell ───────────────────────────── */
|
||||
function EditableCell({
|
||||
value,
|
||||
onSave,
|
||||
rowId,
|
||||
colKey,
|
||||
isSticky,
|
||||
width,
|
||||
disabled,
|
||||
isFirstNonSticky,
|
||||
align,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (v: string) => Promise<void>;
|
||||
rowId: string;
|
||||
colKey: string;
|
||||
isSticky?: boolean;
|
||||
width?: number;
|
||||
disabled?: boolean;
|
||||
isFirstNonSticky?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => setEditValue(value ?? ""), [value]);
|
||||
|
||||
const startEdit = () => !disabled && setEditing(true);
|
||||
const finishEdit = async () => {
|
||||
const final = colKey === "ccllc_jobnumber" ? editValue.replace(/,/g, "") : editValue;
|
||||
if (final !== value) await onSave(final);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
minWidth: width ? `${width}px` : undefined,
|
||||
maxWidth: width ? `${width}px` : undefined,
|
||||
width: width ? `${width}px` : undefined,
|
||||
borderTop: "1px solid #000",
|
||||
borderLeft: isFirstNonSticky ? "none" : "1px solid #000",
|
||||
borderRight: colKey === "ccllc_jobnumber" ? "none" : "1px solid #000",
|
||||
borderBottom: "1px solid #000",
|
||||
boxSizing: "border-box",
|
||||
position: isSticky ? "sticky" : undefined,
|
||||
left: isSticky && colKey === "ccllc_jobnumber" ? 0 : isSticky && colKey === "ccllc_jobname" ? 100 : undefined,
|
||||
zIndex: isSticky ? 1500 : undefined,
|
||||
background: isSticky ? "#fff" : undefined,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
padding: "6px 10px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
userSelect: "none",
|
||||
textAlign: align || "left",
|
||||
};
|
||||
|
||||
return (
|
||||
<td style={cellStyle} onClick={startEdit} tabIndex={disabled ? -1 : 0}>
|
||||
{editing && !disabled ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={finishEdit}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") finishEdit();
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(value ?? "");
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
font: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
textAlign: align || "left",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ display: "block", width: "100%", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{colKey === "ccllc_jobnumber" ? (value ?? "").replace(/,/g, "") : value}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── EditableHeaderCell ─────────────────────────── */
|
||||
function EditableHeaderCell({
|
||||
value,
|
||||
onSave,
|
||||
colKey,
|
||||
isSticky,
|
||||
width,
|
||||
isFirstNonSticky,
|
||||
align,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (newValue: string) => void;
|
||||
colKey: string;
|
||||
isSticky?: boolean;
|
||||
width?: number;
|
||||
isFirstNonSticky?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => setEditValue(value), [value]);
|
||||
|
||||
const handleCellClick = (e: React.MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest(".header-dropdown-btn")) {
|
||||
setEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editValue !== value) onSave(editValue);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => handleSave();
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") handleSave();
|
||||
else if (e.key === "Escape") {
|
||||
setEditValue(value);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sticky header cells for "Job Number" and "Job Name" should have top: 0 to stay fixed on vertical scroll
|
||||
const isStickyHeader = isSticky && (colKey === "ccllc_jobnumber" || colKey === "ccllc_jobname");
|
||||
|
||||
// Always sticky at top for all header cells
|
||||
const headerStyle: React.CSSProperties = {
|
||||
minWidth: width ? `${width}px` : "60px",
|
||||
maxWidth: width ? `${width + 20}px` : undefined,
|
||||
width: width ? `${width}px` : undefined,
|
||||
borderTop: "1px solid #000",
|
||||
borderLeft: isFirstNonSticky ? "none" : "1px solid #000",
|
||||
borderRight: value === "Job Number" ? "none" : "1px solid #000",
|
||||
borderBottom: "1px solid #000",
|
||||
boxSizing: "border-box",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
left: isSticky && colKey === "ccllc_jobnumber" ? 0 : isSticky && colKey === "ccllc_jobname" ? 100 : undefined,
|
||||
zIndex: isSticky
|
||||
? 2000 // sticky columns in header
|
||||
: 1500, // normal header
|
||||
background: "#f3f3f3",
|
||||
whiteSpace: "normal",
|
||||
padding: 0,
|
||||
fontWeight: 600,
|
||||
textAlign: align || "left",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
minHeight: "40px",
|
||||
height: "40px",
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
const flexRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "40px",
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
const labelAreaStyle: React.CSSProperties = {
|
||||
flex: "1 1 0",
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 8px 8px 10px",
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const dropdownCellStyle: React.CSSProperties = {
|
||||
width: "20px",
|
||||
minWidth: "20px",
|
||||
maxWidth: "20px",
|
||||
height: "40px",
|
||||
boxSizing: "border-box",
|
||||
overflow: "visible",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
paddingBottom: "4px",
|
||||
zIndex: 1600,
|
||||
};
|
||||
|
||||
const dropdownButtonStyle: React.CSSProperties = {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
minHeight: "16px",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#C0C0C0",
|
||||
zIndex: 1600,
|
||||
};
|
||||
|
||||
const renderHeaderText = (text: string) => {
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<span style={{ display: "block", width: "100%", wordBreak: "break-word", whiteSpace: "pre-line" }}>
|
||||
{lines.map((line, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{line}
|
||||
{idx < lines.length - 1 ? <br /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<th style={headerStyle} onClick={handleCellClick} tabIndex={0} aria-label={`Edit column: ${value}`}>
|
||||
<div style={flexRowStyle}>
|
||||
<div style={labelAreaStyle}>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
font: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
color: "#222",
|
||||
whiteSpace: "pre-line",
|
||||
textAlign: align || "left",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
renderHeaderText(editValue)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={dropdownCellStyle}>
|
||||
<Menu>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
icon={<ChevronDownRegular fontSize="15px" />}
|
||||
aria-label="Column options"
|
||||
className="header-dropdown-btn"
|
||||
style={dropdownButtonStyle}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</MenuTrigger>
|
||||
<MenuPopover style={{ zIndex: 1700 }}>
|
||||
<MenuList>
|
||||
<MenuItem>Column options</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────── MAIN COMPONENT ─────────────────────────────── */
|
||||
const BATCH_SIZE = 50; // Show 50 records at a time
|
||||
const DEFAULT_SEARCH_BATCH_SIZE = 10;
|
||||
const SEARCH_ALL_PAGE_SIZE = 100;
|
||||
|
||||
const GeneratedComponent: React.FC<GeneratedComponentProps> = ({ dataApi }) => {
|
||||
const [mainRows, setMainRows] = useState<ReadableTableRow<ccllc_job>[]>([]);
|
||||
const [searchRows, setSearchRows] = useState<ReadableTableRow<ccllc_job>[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searchAll, setSearchAll] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [headerNames, setHeaderNames] = useState({ ...DEFAULT_FRIENDLY_NAMES });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [rowToDelete, setRowToDelete] = useState<ReadableTableRow<ccllc_job> | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { rows } = await dataApi.queryTable("ccllc_job", {
|
||||
select: DEFAULT_COLUMN_ORDER,
|
||||
pageSize: BATCH_SIZE,
|
||||
orderBy: "ccllc_jobnumber desc",
|
||||
});
|
||||
const clean = rows.map(r => {
|
||||
const { ["@Odata.Etag"]: _, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
if (mounted) setMainRows(clean);
|
||||
} catch {
|
||||
if (mounted) setError("Failed to load jobs.");
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, [dataApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchText.trim()) {
|
||||
setSearchRows([]);
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setSearchLoading(true);
|
||||
const cols = [
|
||||
"ccllc_jobname", "ccllc_jobnumber", "ccllc_jobaddress", "ccllc_clientcompany",
|
||||
"ccllc_contactperson", "ccllc_contactemail", "ccllc_notes", "ccllc_estimatorname",
|
||||
"ccllc_officerepresentative",
|
||||
];
|
||||
const filter = cols
|
||||
.map(c => `contains(${c},'${searchText.replace(/'/g, "''")}')`)
|
||||
.join(" or ");
|
||||
|
||||
const allResults: ReadableTableRow<ccllc_job>[] = [];
|
||||
const pageSize = searchAll ? 100 : DEFAULT_SEARCH_BATCH_SIZE; // 100 or 10
|
||||
let skip = 0;
|
||||
let hasMore = searchAll; // Only paginate if "Show All"
|
||||
|
||||
try {
|
||||
// Initial request
|
||||
let opts: QueryTableOptions<ccllc_job> = {
|
||||
select: DEFAULT_COLUMN_ORDER,
|
||||
pageSize,
|
||||
orderBy: "ccllc_jobnumber desc",
|
||||
filter,
|
||||
skip,
|
||||
};
|
||||
|
||||
let { rows, nextLink } = await dataApi.queryTable("ccllc_job", opts);
|
||||
|
||||
// Clean and store first page
|
||||
const clean = rows.map(r => {
|
||||
const { ["@Odata.Etag"]: _, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
allResults.push(...clean);
|
||||
|
||||
// Only continue if "Show All" and there's more
|
||||
while (!cancelled && searchAll && nextLink && clean.length === pageSize) {
|
||||
skip += pageSize;
|
||||
opts = { ...opts, skip };
|
||||
const result = await dataApi.queryTable("ccllc_job", opts);
|
||||
rows = result.rows;
|
||||
nextLink = result.nextLink;
|
||||
|
||||
const cleanPage = rows.map(r => {
|
||||
const { ["@Odata.Etag"]: _, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
allResults.push(...cleanPage);
|
||||
}
|
||||
|
||||
if (!cancelled) setSearchRows(allResults);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError("Search failed.");
|
||||
} finally {
|
||||
if (!cancelled) setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
}, [searchText, searchAll, dataApi]);
|
||||
|
||||
const handleCellSave = async (rowId: string, colKey: string, newValue: string) => {
|
||||
try {
|
||||
const all = [...searchRows, ...mainRows];
|
||||
const row = all.find(r => r.ccllc_jobid === rowId);
|
||||
if (!row) return;
|
||||
const final = colKey === "ccllc_jobnumber" ? newValue.replace(/,/g, "") : newValue;
|
||||
if ((row as any)[colKey] === final) return;
|
||||
|
||||
await dataApi.updateRow("ccllc_job", rowId, { [colKey]: final });
|
||||
|
||||
const update = (list: typeof all) =>
|
||||
list.map(r => (r.ccllc_jobid === rowId ? { ...r, [colKey]: final } : r));
|
||||
|
||||
setSearchRows(update);
|
||||
setMainRows(update);
|
||||
} catch {
|
||||
setError("Update failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderSave = (col: string, name: string) => {
|
||||
setHeaderNames(p => ({ ...p, [col]: name }));
|
||||
};
|
||||
|
||||
const openDelete = (row: ReadableTableRow<ccllc_job>) => {
|
||||
setRowToDelete(row);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!rowToDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await dataApi.deleteRow("ccllc_job", rowToDelete.ccllc_jobid);
|
||||
const id = rowToDelete.ccllc_jobid;
|
||||
setSearchRows(p => p.filter(r => r.ccllc_jobid !== id));
|
||||
setMainRows(p => p.filter(r => r.ccllc_jobid !== id));
|
||||
} catch {
|
||||
setError("Delete failed.");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setRowToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleCols = (mainRows.length
|
||||
? getJobColumns(mainRows[0])
|
||||
: DEFAULT_COLUMN_ORDER
|
||||
).filter(c => c !== "@Odata.Etag" && DEFAULT_COLUMN_VISIBILITY[c] !== false);
|
||||
|
||||
const firstNonStickyIdx = visibleCols.findIndex(
|
||||
c => c !== "ccllc_jobnumber" && c !== "ccllc_jobname"
|
||||
);
|
||||
|
||||
const rows = searchText.trim() ? searchRows : mainRows;
|
||||
const isShowAllSearch = searchText.trim() && searchAll;
|
||||
const displayedRows = isShowAllSearch ? rows : rows.slice(0, 50);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxSizing: "border-box",
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12, flexShrink: 0, minHeight: 36 }}>
|
||||
<div style={{ border: "2px solid #000", background: "#f3f3f3", borderRadius: 8, padding: "0px 16px",display: "flex", height: 36, }}>
|
||||
<Text as="h1" size={700} weight="semibold">Job List</Text>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(_, d) => setSearchText(d.value)}
|
||||
placeholder="Search jobs..."
|
||||
contentBefore={<SearchRegular />}
|
||||
style={{ height: 36 }}
|
||||
/>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
onClick={() => setSearchAll(a => !a)}
|
||||
style={{ background: searchAll ? "#e5ffe5" : "#f3f3f3", border: "1px solid #000", }}
|
||||
>
|
||||
{searchAll ? "Top 10" : "Show All"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Text style={{ color: "#b00", marginBottom: 8, flexShrink: 0 }}>{error}</Text>}
|
||||
|
||||
{/* TABLE CONTAINER */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
border: "1px solid #000",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{(loading || searchLoading) && displayedRows.length === 0 ? (
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||
<Spinner size="medium" label="Loading…" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
maxHeight: 600, // Force vertical scroll for more than 50 rows
|
||||
overflowY: "auto",
|
||||
overflowX: "auto",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: 0,
|
||||
width: "max-content",
|
||||
minWidth: "100%",
|
||||
tableLayout: "fixed",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{visibleCols.map((col, idx) => (
|
||||
<EditableHeaderCell
|
||||
key={col}
|
||||
colKey={col}
|
||||
value={headerNames[col] || humanize(col)}
|
||||
onSave={v => handleHeaderSave(col, v)}
|
||||
isSticky={col === "ccllc_jobnumber" || col === "ccllc_jobname"}
|
||||
width={DEFAULT_COLUMN_WIDTHS[col]}
|
||||
isFirstNonSticky={idx === firstNonStickyIdx}
|
||||
align={DEFAULT_COLUMN_ALIGNMENTS[col]}
|
||||
/>
|
||||
))}
|
||||
<th
|
||||
style={{
|
||||
minWidth: 60,
|
||||
width: 60,
|
||||
border: "1px solid #000",
|
||||
background: "#f3f3f3",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
right: undefined,
|
||||
zIndex: 1500,
|
||||
transform: "none",
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{displayedRows.map(row => (
|
||||
<tr key={row.ccllc_jobid}>
|
||||
{visibleCols.map((col, idx) => (
|
||||
<EditableCell
|
||||
key={col}
|
||||
value={row[col as keyof typeof row]?.toString() ?? ""}
|
||||
onSave={v => handleCellSave(row.ccllc_jobid, col, v)}
|
||||
rowId={row.ccllc_jobid}
|
||||
colKey={col}
|
||||
isSticky={col === "ccllc_jobnumber" || col === "ccllc_jobname"}
|
||||
width={DEFAULT_COLUMN_WIDTHS[col]}
|
||||
disabled={col === "ccllc_jobid"}
|
||||
isFirstNonSticky={idx === firstNonStickyIdx}
|
||||
align={DEFAULT_COLUMN_ALIGNMENTS[col]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<td
|
||||
style={{
|
||||
minWidth: 60,
|
||||
width: 60,
|
||||
borderTop: "1px solid #000",
|
||||
borderBottom: "1px solid #000",
|
||||
borderLeft: "1px solid #000",
|
||||
background: "#fff",
|
||||
position: "static",
|
||||
right: undefined,
|
||||
zIndex: undefined,
|
||||
transform: "none",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => openDelete(row)}
|
||||
disabled={deleting}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={(_, { open }) => !open && setDeleteDialogOpen(false)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<Text block size={400} style={{ marginBottom: 16 }}>
|
||||
Are you sure you want to delete this job record?
|
||||
</Text>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
style={{ backgroundColor: "#b00", color: "#fff" }}
|
||||
onClick={confirmDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TablePage = GeneratedComponent;
|
||||
export default TablePage;
|
||||
@@ -0,0 +1,23 @@
|
||||
// table.tsx
|
||||
import { Table, TableBody, TableCell, TableRow } from '@fluentui/react-components';
|
||||
|
||||
const SimpleTable = () => (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><strong>Name</strong></TableCell>
|
||||
<TableCell><strong>Age</strong></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Alice</TableCell>
|
||||
<TableCell>30</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Bob</TableCell>
|
||||
<TableCell>25</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
export default SimpleTable;
|
||||
Reference in New Issue
Block a user