Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e719e1a9 | ||
|
|
63e73e7fe6 |
@@ -290,10 +290,21 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity");
|
const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity");
|
||||||
const orgDescriptors = descriptors.filter((d) => d.groupKind === "org");
|
const orgDescriptors = descriptors.filter((d) => d.groupKind === "org");
|
||||||
|
|
||||||
// 2. Org Contact (display_name + every org-side custom field).
|
// 2. Org Contact (display_name + every org-side custom field, plus file-name
|
||||||
const orgSelect = ["id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField)];
|
// joins for any file-typed org fields so the staff report can render a
|
||||||
// 3. Activities (every activity-side custom field + file-name/url joins).
|
// label next to the link).
|
||||||
const fileFieldRefs = activityDescriptors
|
const orgFileNameRefs = orgDescriptors
|
||||||
|
.filter((d) => d.render === "file")
|
||||||
|
.map((d) => `${d.civiField}.file_name`);
|
||||||
|
const orgSelect = [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"contact_type",
|
||||||
|
...orgDescriptors.map((d) => d.civiField),
|
||||||
|
...orgFileNameRefs,
|
||||||
|
];
|
||||||
|
// 3. Activities (every activity-side custom field + file-name joins).
|
||||||
|
const activityFileNameRefs = activityDescriptors
|
||||||
.filter((d) => d.render === "file")
|
.filter((d) => d.render === "file")
|
||||||
.map((d) => `${d.civiField}.file_name`);
|
.map((d) => `${d.civiField}.file_name`);
|
||||||
const activitySelect = [
|
const activitySelect = [
|
||||||
@@ -303,7 +314,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
"source_contact_id.display_name",
|
"source_contact_id.display_name",
|
||||||
ACTIVITY_STAGE_FIELD,
|
ACTIVITY_STAGE_FIELD,
|
||||||
...activityDescriptors.map((d) => d.civiField),
|
...activityDescriptors.map((d) => d.civiField),
|
||||||
...fileFieldRefs,
|
...activityFileNameRefs,
|
||||||
];
|
];
|
||||||
// 4. Option groups for every select/multiselect + the stage option group.
|
// 4. Option groups for every select/multiselect + the stage option group.
|
||||||
const optionGroupIds = Array.from(
|
const optionGroupIds = Array.from(
|
||||||
@@ -341,6 +352,53 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
|
|
||||||
const rows = activityRes.values ?? [];
|
const rows = activityRes.values ?? [];
|
||||||
|
|
||||||
|
// Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=<JWT>; the fcs
|
||||||
|
// is a JWT signed with Civi's site key. Without it, the file handler
|
||||||
|
// crashes on a null JWT decode. We don't have the site key on this side,
|
||||||
|
// so ask Civi for signed URLs via APIv4 Attachment.get and pass them
|
||||||
|
// straight through to the client. If Attachment.get doesn't expose `url`
|
||||||
|
// on this Civi version, the frontend falls back to a bare /civicrm/file
|
||||||
|
// URL (still broken, but no worse than before).
|
||||||
|
const fileIds = new Set<number>();
|
||||||
|
const collectId = (v: unknown) => {
|
||||||
|
if (v === null || v === undefined || v === "") return;
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
if (Number.isFinite(n) && n > 0) fileIds.add(n);
|
||||||
|
};
|
||||||
|
for (const d of activityDescriptors) {
|
||||||
|
if (d.render !== "file") continue;
|
||||||
|
for (const row of rows) collectId(row[d.civiField]);
|
||||||
|
}
|
||||||
|
for (const d of orgDescriptors) {
|
||||||
|
if (d.render !== "file") continue;
|
||||||
|
collectId(org[d.civiField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlByFileId = new Map<number, string>();
|
||||||
|
if (fileIds.size > 0) {
|
||||||
|
try {
|
||||||
|
const attachRes = await civi<{ id: number; url?: string }>("Attachment", "get", {
|
||||||
|
select: ["id", "url"],
|
||||||
|
where: [["id", "IN", Array.from(fileIds)]],
|
||||||
|
// Bypass permission checks: we already gated this whole route on
|
||||||
|
// STAFF_REPORT_KEY, and we want every file the org's activities
|
||||||
|
// reference, regardless of which contact "owns" them.
|
||||||
|
checkPermissions: false,
|
||||||
|
limit: 0,
|
||||||
|
});
|
||||||
|
for (const a of attachRes.values ?? []) {
|
||||||
|
if (typeof a.url === "string" && a.url.length > 0) {
|
||||||
|
urlByFileId.set(a.id, a.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[staff/report] Attachment.get failed; file links will lack fcs:",
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Activity summaries.
|
// Activity summaries.
|
||||||
const activities: ActivitySummary[] = rows.map((r) => ({
|
const activities: ActivitySummary[] = rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -373,11 +431,23 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
groupKind: "org",
|
groupKind: "org",
|
||||||
fields: orgDescriptors.map((d) => {
|
fields: orgDescriptors.map((d) => {
|
||||||
const raw = org[d.civiField];
|
const raw = org[d.civiField];
|
||||||
const history: FieldHistoryEntry[] =
|
if (raw === null || raw === undefined || raw === "") {
|
||||||
raw === null || raw === undefined || raw === ""
|
return { descriptor: d, history: [] };
|
||||||
? []
|
}
|
||||||
: [{ activityId: 0, date: "", value: raw }];
|
let value: unknown = raw;
|
||||||
return { descriptor: d, history };
|
if (d.render === "file") {
|
||||||
|
const fid = Number(raw);
|
||||||
|
const fname = org[`${d.civiField}.file_name`];
|
||||||
|
value = {
|
||||||
|
id: raw,
|
||||||
|
file_name: typeof fname === "string" ? fname : undefined,
|
||||||
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
descriptor: d,
|
||||||
|
history: [{ activityId: 0, date: "", value }],
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -400,9 +470,11 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
let value: unknown = v;
|
let value: unknown = v;
|
||||||
if (d.render === "file") {
|
if (d.render === "file") {
|
||||||
const fname = row[`${d.civiField}.file_name`];
|
const fname = row[`${d.civiField}.file_name`];
|
||||||
|
const fid = Number(v);
|
||||||
value = {
|
value = {
|
||||||
id: v,
|
id: v,
|
||||||
file_name: typeof fname === "string" ? fname : undefined,
|
file_name: typeof fname === "string" ? fname : undefined,
|
||||||
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
||||||
|
|||||||
@@ -453,16 +453,24 @@ function FieldValue({
|
|||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
}) {
|
}) {
|
||||||
if (field.descriptor.render === "file") {
|
if (field.descriptor.render === "file") {
|
||||||
const v = entry.value as { id?: number | string; file_name?: string } | null;
|
const v = entry.value as
|
||||||
|
| { id?: number | string; file_name?: string; url?: string }
|
||||||
|
| null;
|
||||||
if (!v || v.id === undefined) return <span>—</span>;
|
if (!v || v.id === undefined) return <span>—</span>;
|
||||||
const id = String(v.id);
|
const id = String(v.id);
|
||||||
const name = v.file_name ?? `file-${id}`;
|
const name = v.file_name ?? `file-${id}`;
|
||||||
// Civi serves uploaded files at /civicrm/file?reset=1&id=<id>.
|
// Prefer the Civi-signed URL (carries the fcs JWT) returned by
|
||||||
// The staff member is already authenticated to Civi (they came from
|
// Attachment.get; Civi's file handler crashes on a null fcs decode if we
|
||||||
// there); the browser sends their session cookie automatically.
|
// hit /civicrm/file?id=X bare. Fall back to a bare URL only if signed
|
||||||
const href = civiBaseUrl
|
// URLs weren't available (e.g. older Civi without `url` on Attachment).
|
||||||
? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`
|
let href = "#";
|
||||||
: "#";
|
if (v.url) {
|
||||||
|
href = v.url.startsWith("http")
|
||||||
|
? v.url
|
||||||
|
: `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`;
|
||||||
|
} else if (civiBaseUrl) {
|
||||||
|
href = `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -545,6 +553,28 @@ interface Y1MatrixData {
|
|||||||
periodLetter: "Q" | "M";
|
periodLetter: "Q" | "M";
|
||||||
usedNames: Set<string>;
|
usedNames: Set<string>;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Y1 Monthly Sales Target Civi machine names — irregular. M1 dropped the
|
||||||
|
* trailing period from "Y1_Monthly_Sales_Targets", M3 lives in a field named
|
||||||
|
* "_M2" (Civi schema error captured in the original form mapping), and the
|
||||||
|
* rest follow "Y1_Monthly_Sales_Target_M<n>". Hardcoded here so the staff
|
||||||
|
* report can fold these into the monthly Y1 matrix.
|
||||||
|
*/
|
||||||
|
const Y1_MONTHLY_SALES_TARGET_FIELDS: Record<number, string> = {
|
||||||
|
1: "Y1_Monthly_Sales_Targets",
|
||||||
|
2: "Y1_Monthly_Sales_Targets_M2",
|
||||||
|
3: "Y1_Monthly_Sales_Target_M2", // intentional: Civi name says M2, value is M3.
|
||||||
|
4: "Y1_Monthly_Sales_Target_M4",
|
||||||
|
5: "Y1_Monthly_Sales_Target_M5",
|
||||||
|
6: "Y1_Monthly_Sales_Target_M6",
|
||||||
|
7: "Y1_Monthly_Sales_Target_M7",
|
||||||
|
8: "Y1_Monthly_Sales_Target_M8",
|
||||||
|
9: "Y1_Monthly_Sales_Target_M9",
|
||||||
|
10: "Y1_Monthly_Sales_Target_M10",
|
||||||
|
11: "Y1_Monthly_Sales_Target_M11",
|
||||||
|
12: "Y1_Monthly_Sales_Target_M12",
|
||||||
|
};
|
||||||
|
|
||||||
function collectY1MatrixByPeriod(
|
function collectY1MatrixByPeriod(
|
||||||
filled: StaffReportField[],
|
filled: StaffReportField[],
|
||||||
periodLetter: "Q" | "M",
|
periodLetter: "Q" | "M",
|
||||||
@@ -555,6 +585,7 @@ function collectY1MatrixByPeriod(
|
|||||||
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
||||||
const periodsSet = new Set<number>();
|
const periodsSet = new Set<number>();
|
||||||
const metricLabel = new Map<string, string>();
|
const metricLabel = new Map<string, string>();
|
||||||
|
const byName = new Map(filled.map((f) => [f.descriptor.name, f]));
|
||||||
|
|
||||||
for (const f of filled) {
|
for (const f of filled) {
|
||||||
const m = nameRe.exec(f.descriptor.name);
|
const m = nameRe.exec(f.descriptor.name);
|
||||||
@@ -575,6 +606,24 @@ function collectY1MatrixByPeriod(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Y1 Monthly Sales Target — fold in the irregular fields that don't fit
|
||||||
|
// the Y1_M<n>_<metric> regex above. Only present in the monthly matrix.
|
||||||
|
if (periodLetter === "M") {
|
||||||
|
const byMonth = new Map<number, StaffReportField>();
|
||||||
|
for (const [periodStr, fieldName] of Object.entries(Y1_MONTHLY_SALES_TARGET_FIELDS)) {
|
||||||
|
const f = byName.get(fieldName);
|
||||||
|
if (!f) continue;
|
||||||
|
const period = Number(periodStr);
|
||||||
|
byMonth.set(period, f);
|
||||||
|
used.add(fieldName);
|
||||||
|
periodsSet.add(period);
|
||||||
|
}
|
||||||
|
if (byMonth.size > 0) {
|
||||||
|
byMetric.set("Sales_Target", byMonth);
|
||||||
|
metricLabel.set("Sales_Target", "Sales Target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (byMetric.size === 0) return null;
|
if (byMetric.size === 0) return null;
|
||||||
const periods = Array.from(periodsSet).sort((a, b) => a - b);
|
const periods = Array.from(periodsSet).sort((a, b) => a - b);
|
||||||
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
|
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user