INIT frontend

This commit is contained in:
2025-11-28 11:55:01 -06:00
parent f99d21805f
commit 5029278228
31 changed files with 3766 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { initializeAuth, getToken } from "./auth.js";
import { log, error } from "./logger.js";
async function startApp() {
log("App starting...");
await initializeAuth();
try {
const token = await getToken();
log("Final acquired token:", token.slice(0, 30) + "...");
document.querySelector("#content").textContent =
"Logged in successfully. Token acquired.";
} catch (e) {
error("Failed to authenticate:", e);
document.querySelector("#content").textContent =
"Authentication failed. Check console.";
}
}
startApp();
+77
View File
@@ -0,0 +1,77 @@
import { msalInstance, loginRequest } from "./msalConfig.js";
import { log, warn, error } from "./logger.js";
export async function initializeAuth() {
log("Running handleRedirectPromise...");
return msalInstance.handleRedirectPromise().then((response) => {
if (response) {
log("Redirect response received:", response);
if (response.account) {
log("Setting active account from redirect:", response.account.username);
msalInstance.setActiveAccount(response.account);
}
} else {
log("No redirect response (normal page load).");
}
const accounts = msalInstance.getAllAccounts();
log("Existing accounts:", accounts);
if (accounts.length > 0 && !msalInstance.getActiveAccount()) {
log("Setting first account as active:", accounts[0].username);
msalInstance.setActiveAccount(accounts[0]);
}
});
}
export async function getToken() {
const active = msalInstance.getActiveAccount();
log("Starting token acquisition. Active account:", active);
try {
log("Attempting acquireTokenSilent...");
const result = await msalInstance.acquireTokenSilent({
...loginRequest,
account: active
});
log("Silent token success:");
log(" Expires:", new Date(result.expiresOn).toLocaleString());
log(" Scopes:", result.scopes);
log(" Access token (first 20 chars):", result.accessToken.slice(0, 20) + "...");
return result.accessToken;
} catch (err) {
warn("Silent token failed:", err);
// Only do popup if it truly needs human interaction
if (err.name === "InteractionRequiredAuthError" ||
err.errorCode === "interaction_required") {
log("Interaction required → Launching popup login...");
const popup = await msalInstance.loginPopup(loginRequest);
log("Popup login success. Account:", popup.account.username);
msalInstance.setActiveAccount(popup.account);
log("Retrying silent acquisition after popup...");
const refreshed = await msalInstance.acquireTokenSilent({
...loginRequest,
account: popup.account
});
log("Post-popup silent token success:");
log(" Expires:", new Date(refreshed.expiresOn).toLocaleString());
log(" Access token (first 20 chars):", refreshed.accessToken.slice(0, 20) + "...");
return refreshed.accessToken;
}
error("Unexpected token error:", err);
throw err;
}
}
+834
View File
@@ -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;
+23
View File
@@ -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;
+13
View File
@@ -0,0 +1,13 @@
export const DEBUG = true; // turn off for production
export function log(...args) {
if (DEBUG) console.log("[MSAL DEBUG]", ...args);
}
export function warn(...args) {
if (DEBUG) console.warn("[MSAL WARN]", ...args);
}
export function error(...args) {
if (DEBUG) console.error("[MSAL ERROR]", ...args);
}
+21
View File
@@ -0,0 +1,21 @@
import { PublicClientApplication } from "@azure/msal-browser";
export const msalConfig = {
auth: {
clientId: "3c846e71-9609-40e1-b458-0eb805e21b9f",
authority: "https://login.microsoftonline.com/3fd97ea7-b124-41f1-855f-52d8ac3b16c7",
redirectUri: "http://localhost:3000/auth-response",
postLogoutRedirectUri: "http://localhost:3000/auth-response"
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: true
}
};
export const loginRequest = {
scopes: ["User.Read"]
};
log("Initializing MSAL...");
export const msalInstance = new PublicClientApplication(msalConfig);
log("MSAL instance created:", msalInstance);
+13
View File
@@ -0,0 +1,13 @@
import app from './pb';
const PORT = 8081;
console.log(`✅ Starting Impersonation server at http://localhost:${PORT}`);
Bun.serve({
port: PORT,
fetch: app.fetch,
});
console.log(`✅ Impersonation server running at http://localhost:${PORT}`);
console.log(`Press Ctrl+C to stop`);
+91
View File
@@ -0,0 +1,91 @@
import { Hono } from 'hono';
import PocketBase from 'pocketbase';
const app = new Hono();
// --- PocketBase singleton ---
const pb = new PocketBase(process.env.PB_URL || 'http://10.10.1.47:8080');
// --- User type ---
export type PBUser = {
id: string;
role: 'Admin' | 'Superuser' | string;
display_name?: string;
email?: string;
};
// --- Middleware: require Admin or Superuser ---
async function requireAdmin(c: any, next: () => Promise<void>) {
const authHeader = c.req.header('authorization')?.replace('Bearer ', '');
if (!authHeader) return c.text('Unauthorized', 401);
pb.authStore.save(authHeader, null);
const currentUser = pb.authStore.record as PBUser | null;
if (!currentUser) return c.text('Unauthorized', 401);
if (currentUser.role !== 'Admin' && currentUser.role !== 'Superuser') {
return c.text('Forbidden', 403);
}
c.set('currentUser', currentUser);
await next();
}
// --- Impersonation Endpoint ---
app.post('/api/impersonate', requireAdmin, async (c: any) => {
const actor = c.get('currentUser') as PBUser;
if (!actor) return c.text('Unauthorized', 401);
const { targetUserIdentifier } = await c.req.json(); // can be ID or email
let targetUser: PBUser | null = null;
// Try fetching by ID first
try {
targetUser = await pb.collection('users').getOne(targetUserIdentifier) as PBUser;
} catch {
// If failed, try fetching by email
const users = await pb.collection('users').getFullList({ sort: '-created' });
const foundUser = users.find(u => u.email === targetUserIdentifier);
if (foundUser) {
targetUser = {
id: foundUser.id,
role: foundUser.role,
display_name: foundUser.display_name,
email: foundUser.email
};
} else {
targetUser = null;
}
}
if (!targetUser) return c.text('Target user not found', 404);
// Prevent Admin → Admin impersonation
if (actor.role === 'Admin' && targetUser.role === 'Admin') {
return c.text('Admins cannot impersonate other admins', 403);
}
// Log impersonation
await pb.collection('admin_audit_logs').create({
admin_id: actor.id,
admin_type: actor.role === 'Superuser' ? 'superuser' : 'user',
target_user_id: targetUser.id,
action: 'IMPERSONATE_USER',
level: actor.role === 'Superuser' ? 'SUPERUSER' : 'ADMIN',
created_at: new Date().toISOString(),
target_display_name: targetUser.display_name || null,
actor_display_name: actor.display_name || null,
});
// Issue impersonation token
const tokenResponse = await pb.send('/api/collections/users/auth-refresh', { method: 'POST' });
const json = await tokenResponse.json();
return c.json({ token: json.token });
});
export default app;
export { pb };
+88
View File
@@ -0,0 +1,88 @@
import { Hono } from 'hono';
import { serve } from 'bun';
import { serveStatic } from 'hono/bun';
const app = new Hono();
app.get("/table", (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Power Apps Table</title>
<link rel="stylesheet" href="https://static2.sharepointonline.com/files/fabric/office-ui-fabric-core/11.1.0/css/fabric.min.css" />
<style>
body, html, #root { margin:0; padding:0; height:100%; font-family:'Segoe UI',sans-serif; }
#root { padding:2rem; overflow:auto; }
</style>
</head>
<body>
<div id="root"></div>
<!-- LOAD REACT GLOBALLY -->
<script type="module">
// Load React from CDN into window
window.React = (await import("https://esm.sh/react@18")).default;
window.ReactDOM = (await import("https://esm.sh/react-dom@18")).default;
console.log("React loaded:", window.React);
// Now load your component
import("./table.js").then(module => {
const TablePage = module.default;
console.log("TablePage:", TablePage);
ReactDOM.createRoot(document.getElementById("root"))
.render(React.createElement(TablePage));
}).catch(err => {
console.error("Failed to load table.js:", err);
});
</script>
</body>
</html>
`);
});
// === CONFIG: Load from environment variables ===
const API_URL = process.env.API_URL!;
const API_KEY = process.env.API_KEY!;
const port = Number(process.env.PORT || 3000);
// Serve static assets from /public
//app.use('*', serveStatic({ root: './public' }));
// Default route serves index.html (handled by static middleware); fallback text if not found
// {made this text to text react table} app.get('/', (c) => c.redirect('/index.html'));
//serve({
// port,
// fetch: app.fetch,
//});
//console.log(`Server running on http://localhost:${port}`);
// 2. SERVE ONLY STATIC FILES (NOT ROUTES)
app.use("/table.js", serveStatic({ path: "./public/table.js" })); // serve JS
app.use("/favicon.ico", serveStatic({ path: "./public/favicon.ico" })); // optional
// 3. FALLBACK: serve index.html for home (optional)
app.get("/", (c) => {
return c.html(`
<!DOCTYPE html>
<html><head><title>Home</title></head>
<body style="font-family:Segoe UI;text-align:center;padding:4rem;">
<h1>Power Apps Table</h1>
<p><a href="/table">Open Table</a></p>
</body></html>
`);
});
export default {
port: 3000,
fetch: app.fetch,
};