INIT frontend
This commit is contained in:
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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`);
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user