@burdenoff/microfe-planmagnet 2026.1002.2 → 2026.1008.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generated/wspace-operations.d.ts +574 -0
- package/dist/generated/wspace-operations.d.ts.map +1 -1
- package/dist/generated/wspace-types.d.ts +486 -16
- package/dist/generated/wspace-types.d.ts.map +1 -1
- package/dist/index.js +8 -8
- package/dist/planmagnet/PlanMagnetRoot.d.ts.map +1 -1
- package/dist/planmagnet/PlanMagnetRoot.js +174 -168
- package/dist/planmagnet/PlanMagnetRoot.js.map +1 -1
- package/dist/planmagnet/PlanMagnetRoutes.d.ts.map +1 -1
- package/dist/planmagnet/PlanMagnetRoutes.js +63 -59
- package/dist/planmagnet/PlanMagnetRoutes.js.map +1 -1
- package/dist/planmagnet/board/BulkActions.d.ts.map +1 -1
- package/dist/planmagnet/board/BulkActions.js +106 -55
- package/dist/planmagnet/board/BulkActions.js.map +1 -1
- package/dist/planmagnet/controls/Pickers.d.ts +3 -3
- package/dist/planmagnet/controls/Pickers.js.map +1 -1
- package/dist/planmagnet/dashboards/DashboardUI.d.ts +0 -4
- package/dist/planmagnet/dashboards/DashboardUI.d.ts.map +1 -1
- package/dist/planmagnet/dashboards/DashboardUI.js +6 -13
- package/dist/planmagnet/dashboards/DashboardUI.js.map +1 -1
- package/dist/planmagnet/dashboards/cards/SprintCapacityCard.d.ts.map +1 -1
- package/dist/planmagnet/dashboards/cards/SprintCapacityCard.js +70 -59
- package/dist/planmagnet/dashboards/cards/SprintCapacityCard.js.map +1 -1
- package/dist/planmagnet/forecast/ResourceForecastCard.js +1 -1
- package/dist/planmagnet/forecast/ResourceForecastCard.js.map +1 -1
- package/dist/planmagnet/index.d.ts +1 -1
- package/dist/planmagnet/index.d.ts.map +1 -1
- package/dist/planmagnet/pages/DashboardsProjectManagerPagePage.d.ts +3 -3
- package/dist/planmagnet/pages/DashboardsProjectManagerPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/DashboardsProjectManagerPagePage.js +26 -19
- package/dist/planmagnet/pages/DashboardsProjectManagerPagePage.js.map +1 -1
- package/dist/planmagnet/pages/DashboardsTeamUtilizationPagePage.d.ts +2 -2
- package/dist/planmagnet/pages/DashboardsTeamUtilizationPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/DashboardsTeamUtilizationPagePage.js +18 -12
- package/dist/planmagnet/pages/DashboardsTeamUtilizationPagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProductsCustomerSatisfactionPagePage.d.ts +2 -0
- package/dist/planmagnet/pages/ProductsCustomerSatisfactionPagePage.d.ts.map +1 -0
- package/dist/planmagnet/pages/ProductsCustomerSatisfactionPagePage.js +251 -0
- package/dist/planmagnet/pages/ProductsCustomerSatisfactionPagePage.js.map +1 -0
- package/dist/planmagnet/pages/ProductsProductDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsProductDetailPagePage.js +94 -92
- package/dist/planmagnet/pages/ProductsProductDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProductsReleaseDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProductsReleaseDetailPagePage.js +116 -114
- package/dist/planmagnet/pages/ProductsReleaseDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/ProjectsPlanningDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/ProjectsPlanningDetailPagePage.js +174 -159
- package/dist/planmagnet/pages/ProjectsPlanningDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/TasksCreateTaskPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/TasksCreateTaskPagePage.js +159 -145
- package/dist/planmagnet/pages/TasksCreateTaskPagePage.js.map +1 -1
- package/dist/planmagnet/pages/TasksEditTaskPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/TasksEditTaskPagePage.js +161 -143
- package/dist/planmagnet/pages/TasksEditTaskPagePage.js.map +1 -1
- package/dist/planmagnet/pages/TasksTaskDetailPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/TasksTaskDetailPagePage.js +157 -155
- package/dist/planmagnet/pages/TasksTaskDetailPagePage.js.map +1 -1
- package/dist/planmagnet/pages/TeamInviteMemberPagePage.d.ts.map +1 -1
- package/dist/planmagnet/pages/TeamInviteMemberPagePage.js +142 -82
- package/dist/planmagnet/pages/TeamInviteMemberPagePage.js.map +1 -1
- package/dist/planmagnet/pages/analytics/DeliveryKpisCard.d.ts.map +1 -1
- package/dist/planmagnet/pages/analytics/DeliveryKpisCard.js +97 -99
- package/dist/planmagnet/pages/analytics/DeliveryKpisCard.js.map +1 -1
- package/dist/planmagnet/product/FeatureLinkPanels.js +66 -66
- package/dist/planmagnet/product/ProductFeatureControls.js +38 -38
- package/dist/planmagnet/product/ProductFeatureLinkCard.js +8 -8
- package/dist/planmagnet/product/ProductLineControls.js +83 -83
- package/dist/planmagnet/product/ProductTeamsPanel.js +6 -6
- package/dist/planmagnet/product/routes.d.ts +6 -0
- package/dist/planmagnet/product/routes.d.ts.map +1 -1
- package/dist/planmagnet/product/routes.js +2 -2
- package/dist/planmagnet/product/routes.js.map +1 -1
- package/dist/planmagnet/satisfaction/DeliverySatisfactionSection.d.ts +20 -0
- package/dist/planmagnet/satisfaction/DeliverySatisfactionSection.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/DeliverySatisfactionSection.js +55 -0
- package/dist/planmagnet/satisfaction/DeliverySatisfactionSection.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionFigures.d.ts +30 -0
- package/dist/planmagnet/satisfaction/SatisfactionFigures.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionFigures.js +282 -0
- package/dist/planmagnet/satisfaction/SatisfactionFigures.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionImportDialog.d.ts +9 -0
- package/dist/planmagnet/satisfaction/SatisfactionImportDialog.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionImportDialog.js +390 -0
- package/dist/planmagnet/satisfaction/SatisfactionImportDialog.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionLinkFields.d.ts +17 -0
- package/dist/planmagnet/satisfaction/SatisfactionLinkFields.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionLinkFields.js +77 -0
- package/dist/planmagnet/satisfaction/SatisfactionLinkFields.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionReleaseSelect.d.ts +18 -0
- package/dist/planmagnet/satisfaction/SatisfactionReleaseSelect.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionReleaseSelect.js +87 -0
- package/dist/planmagnet/satisfaction/SatisfactionReleaseSelect.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponseDialog.d.ts +13 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponseDialog.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponseDialog.js +326 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponseDialog.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponsesTable.d.ts +23 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponsesTable.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponsesTable.js +258 -0
- package/dist/planmagnet/satisfaction/SatisfactionResponsesTable.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionSummaryCard.d.ts +6 -0
- package/dist/planmagnet/satisfaction/SatisfactionSummaryCard.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionSummaryCard.js +56 -0
- package/dist/planmagnet/satisfaction/SatisfactionSummaryCard.js.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionWindowPicker.d.ts +16 -0
- package/dist/planmagnet/satisfaction/SatisfactionWindowPicker.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/SatisfactionWindowPicker.js +102 -0
- package/dist/planmagnet/satisfaction/SatisfactionWindowPicker.js.map +1 -0
- package/dist/planmagnet/satisfaction/satisfaction.d.ts +154 -0
- package/dist/planmagnet/satisfaction/satisfaction.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/satisfaction.js +210 -0
- package/dist/planmagnet/satisfaction/satisfaction.js.map +1 -0
- package/dist/planmagnet/satisfaction/satisfactionCsv.d.ts +108 -0
- package/dist/planmagnet/satisfaction/satisfactionCsv.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/satisfactionCsv.js +211 -0
- package/dist/planmagnet/satisfaction/satisfactionCsv.js.map +1 -0
- package/dist/planmagnet/satisfaction/satisfactionForm.d.ts +90 -0
- package/dist/planmagnet/satisfaction/satisfactionForm.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/satisfactionForm.js +90 -0
- package/dist/planmagnet/satisfaction/satisfactionForm.js.map +1 -0
- package/dist/planmagnet/satisfaction/satisfactionOperations.d.ts +116 -0
- package/dist/planmagnet/satisfaction/satisfactionOperations.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/satisfactionOperations.js +86 -0
- package/dist/planmagnet/satisfaction/satisfactionOperations.js.map +1 -0
- package/dist/planmagnet/satisfaction/useSatisfactionAccess.d.ts +20 -0
- package/dist/planmagnet/satisfaction/useSatisfactionAccess.d.ts.map +1 -0
- package/dist/planmagnet/satisfaction/useSatisfactionAccess.js +31 -0
- package/dist/planmagnet/satisfaction/useSatisfactionAccess.js.map +1 -0
- package/dist/planmagnet/skills/RequiredSkillsEditor.d.ts +35 -0
- package/dist/planmagnet/skills/RequiredSkillsEditor.d.ts.map +1 -0
- package/dist/planmagnet/skills/RequiredSkillsEditor.js +124 -0
- package/dist/planmagnet/skills/RequiredSkillsEditor.js.map +1 -0
- package/dist/planmagnet/skills/SkillChips.d.ts +30 -0
- package/dist/planmagnet/skills/SkillChips.d.ts.map +1 -0
- package/dist/planmagnet/skills/SkillChips.js +57 -0
- package/dist/planmagnet/skills/SkillChips.js.map +1 -0
- package/dist/planmagnet/skills/SkillCoverageCard.d.ts +24 -0
- package/dist/planmagnet/skills/SkillCoverageCard.d.ts.map +1 -0
- package/dist/planmagnet/skills/SkillCoverageCard.js +256 -0
- package/dist/planmagnet/skills/SkillCoverageCard.js.map +1 -0
- package/dist/planmagnet/skills/WorkItemRequiredSkills.d.ts +10 -0
- package/dist/planmagnet/skills/WorkItemRequiredSkills.d.ts.map +1 -0
- package/dist/planmagnet/skills/WorkItemRequiredSkills.js +65 -0
- package/dist/planmagnet/skills/WorkItemRequiredSkills.js.map +1 -0
- package/dist/planmagnet/skills/skillOperations.d.ts +52 -0
- package/dist/planmagnet/skills/skillOperations.d.ts.map +1 -0
- package/dist/planmagnet/skills/skillOperations.js +44 -0
- package/dist/planmagnet/skills/skillOperations.js.map +1 -0
- package/dist/planmagnet/skills/skillTags.d.ts +103 -0
- package/dist/planmagnet/skills/skillTags.d.ts.map +1 -0
- package/dist/planmagnet/skills/skillTags.js +115 -0
- package/dist/planmagnet/skills/skillTags.js.map +1 -0
- package/dist/planmagnet/skills/useRequiredSkillsDraft.d.ts +28 -0
- package/dist/planmagnet/skills/useRequiredSkillsDraft.d.ts.map +1 -0
- package/dist/planmagnet/skills/useRequiredSkillsDraft.js +30 -0
- package/dist/planmagnet/skills/useRequiredSkillsDraft.js.map +1 -0
- package/dist/planmagnet/team/PendingInvitations.d.ts +27 -0
- package/dist/planmagnet/team/PendingInvitations.d.ts.map +1 -0
- package/dist/planmagnet/team/PendingInvitations.js +220 -0
- package/dist/planmagnet/team/PendingInvitations.js.map +1 -0
- package/dist/planmagnet/team/invitationEmail.d.ts +35 -0
- package/dist/planmagnet/team/invitationEmail.d.ts.map +1 -0
- package/dist/planmagnet/team/invitationEmail.js +73 -0
- package/dist/planmagnet/team/invitationEmail.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { CombinedGraphQLErrors as e } from "../../node_modules/@apollo/client/errors/CombinedGraphQLErrors.js";
|
|
2
|
+
function t(e) {
|
|
3
|
+
return e.replace(/\s+/g, " ").trim();
|
|
4
|
+
}
|
|
5
|
+
function n(e) {
|
|
6
|
+
return t(e).toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function r(e) {
|
|
9
|
+
for (let t of e) {
|
|
10
|
+
let e = t.codePointAt(0) ?? 0;
|
|
11
|
+
if (e < 32 || e >= 127 && e <= 159) return !0;
|
|
12
|
+
}
|
|
13
|
+
return !1;
|
|
14
|
+
}
|
|
15
|
+
var i = /[,\n]/;
|
|
16
|
+
function a(e, a) {
|
|
17
|
+
let o = [...e], s = new Set(e.map(n)), c = [], l = null;
|
|
18
|
+
for (let e of a.split(i)) {
|
|
19
|
+
let i = t(e);
|
|
20
|
+
if (!i) continue;
|
|
21
|
+
if (r(i)) {
|
|
22
|
+
l ||= { kind: "invalid" }, c.push(i);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if ([...i].length > 64) {
|
|
26
|
+
l ||= {
|
|
27
|
+
kind: "tooLong",
|
|
28
|
+
max: 64
|
|
29
|
+
}, c.push(i);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let a = i.toLowerCase();
|
|
33
|
+
if (s.has(a)) {
|
|
34
|
+
l ||= {
|
|
35
|
+
kind: "duplicate",
|
|
36
|
+
skill: o.find((e) => n(e) === a) ?? i
|
|
37
|
+
};
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (o.length >= 20) {
|
|
41
|
+
l ||= {
|
|
42
|
+
kind: "tooMany",
|
|
43
|
+
max: 20
|
|
44
|
+
}, c.push(i);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
s.add(a), o.push(i);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
skills: o,
|
|
51
|
+
rest: c.join(", "),
|
|
52
|
+
problem: l
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function o(e, t) {
|
|
56
|
+
return e.filter((e, n) => n !== t);
|
|
57
|
+
}
|
|
58
|
+
function s(e, t) {
|
|
59
|
+
return e.length === t.length && e.every((e, n) => e === t[n]);
|
|
60
|
+
}
|
|
61
|
+
function c(t) {
|
|
62
|
+
return e.is(t) && t.errors.find((e) => e.extensions?.field === "requiredSkills")?.message || null;
|
|
63
|
+
}
|
|
64
|
+
var l = {
|
|
65
|
+
UNCOVERED: 0,
|
|
66
|
+
UNKNOWN: 1,
|
|
67
|
+
COVERED: 2,
|
|
68
|
+
NO_DEMAND: 3
|
|
69
|
+
};
|
|
70
|
+
function u(e) {
|
|
71
|
+
return l[e] ?? Object.keys(l).length;
|
|
72
|
+
}
|
|
73
|
+
function d(e) {
|
|
74
|
+
return e.map((e, t) => ({
|
|
75
|
+
row: e,
|
|
76
|
+
index: t
|
|
77
|
+
})).sort((e, t) => u(e.row.status) - u(t.row.status) || e.index - t.index).map(({ row: e }) => e);
|
|
78
|
+
}
|
|
79
|
+
function f(e) {
|
|
80
|
+
let t = {
|
|
81
|
+
UNCOVERED: 0,
|
|
82
|
+
UNKNOWN: 0,
|
|
83
|
+
COVERED: 0,
|
|
84
|
+
NO_DEMAND: 0
|
|
85
|
+
};
|
|
86
|
+
for (let n of e) n.status in t && (t[n.status] += 1);
|
|
87
|
+
return t;
|
|
88
|
+
}
|
|
89
|
+
function p(e) {
|
|
90
|
+
switch (e) {
|
|
91
|
+
case "UNCOVERED": return "destructive";
|
|
92
|
+
case "UNKNOWN": return "warning";
|
|
93
|
+
case "COVERED": return "success";
|
|
94
|
+
case "NO_DEMAND": return "secondary";
|
|
95
|
+
default: return "outline";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function m(e) {
|
|
99
|
+
let t = {};
|
|
100
|
+
return e.projectId && (t.projectId = e.projectId), e.teamId && (t.teamId = e.teamId), e.sprintId && (t.sprintId = e.sprintId), t;
|
|
101
|
+
}
|
|
102
|
+
function h(e) {
|
|
103
|
+
let t = e.toLowerCase();
|
|
104
|
+
return t.startsWith("alphaapp.") || t.startsWith("alpha.") ? "https://alphaapp.crewfoundry.com" : t === "localhost" || t === "127.0.0.1" || t.endsWith(".localhost") || t.endsWith(".local.burdenoff.com") ? "https://crewfoundry.local.burdenoff.com" : "https://app.crewfoundry.com";
|
|
105
|
+
}
|
|
106
|
+
function g(e, t) {
|
|
107
|
+
return `${h(t)}/crewfoundry/hr/employee-profile-page?id=${encodeURIComponent(e)}`;
|
|
108
|
+
}
|
|
109
|
+
function _(e) {
|
|
110
|
+
return `${h(e)}/crewfoundry/hr/employees-page`;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { a as addSkillTags, p as coverageBadgeVariant, f as coverageCounts, m as coverageFilter, g as crewFoundryEmployeeUrl, _ as crewFoundryEmployeesUrl, d as orderCoverage, o as removeSkillAt, c as requiredSkillsRefusal, s as sameSkillList, n as skillKey };
|
|
114
|
+
|
|
115
|
+
//# sourceMappingURL=skillTags.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skillTags.js","names":[],"sources":["../../../src/planmagnet/skills/skillTags.ts"],"sourcesContent":["import { CombinedGraphQLErrors } from '@apollo/client/errors';\n\n/**\n * Skill tags (ANR-07 skill view, ANR-28 skill utilization). PlanMagnet owns the skills a WORK ITEM requires\n * (`WorkItem.requiredSkills`); CrewFoundry owns PEOPLE's skills, which PlanMagnet only reads (owner decision\n * 2026-10-02). The two meet by exact tag matching on a key: the tag trimmed, whitespace runs collapsed, lower-cased.\n * No proficiency levels, no synonyms.\n *\n * The service is the authority on the write rules (wspace-planmagnet-svc `src/utils/required-skills.ts`: tidy,\n * case-insensitive duplicates dropped keeping the first spelling, at most 20 tags of at most 64 characters, an empty\n * or control-character tag refused). The editor applies the same rules as the user types, so the common mistakes\n * are explained before anything is sent; a refusal that still comes back is shown on the field\n * (`requiredSkillsRefusal`).\n */\n\n/** The most skills one work item may require (the service's MAX_REQUIRED_SKILLS). */\nexport const MAX_REQUIRED_SKILLS = 20;\n\n/** The longest one skill may be, in characters (Unicode code points) after tidying (the service's MAX_SKILL_LENGTH). */\nexport const MAX_SKILL_LENGTH = 64;\n\n/** Trim and collapse every run of whitespace to one space: the stored and displayed form of a tag. */\nexport function tidySkill(value: string): string {\n return value.replace(/\\s+/g, ' ').trim();\n}\n\n/** The matching key of a tag. Two tags are the same skill exactly when their keys are equal. */\nexport function skillKey(value: string): string {\n return tidySkill(value).toLowerCase();\n}\n\n/** C0/C1 control characters (and DEL) that survive tidying. The service refuses them; checked by code point. */\nfunction hasControlCharacter(value: string): boolean {\n for (const char of value) {\n const code = char.codePointAt(0) ?? 0;\n if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true;\n }\n return false;\n}\n\n/** Why something the user typed was not added to the list. */\nexport type SkillTagProblem =\n | { kind: 'duplicate'; skill: string }\n | { kind: 'tooLong'; max: number }\n | { kind: 'tooMany'; max: number }\n | { kind: 'invalid' };\n\nexport interface AddSkillTagsResult {\n /** The list with every acceptable new tag appended, in the order typed. */\n skills: string[];\n /** What could not be added, to leave in the input: a refusal never silently drops what was typed. */\n rest: string;\n /** The first reason something was not added, or null when everything typed was added. */\n problem: SkillTagProblem | null;\n}\n\n/** A pasted list (\"React, Postgres\" or one per line) adds several tags at once. */\nconst SEPARATORS = /[,\\n]/;\n\n/**\n * Add what the user typed — one skill, or several separated by commas or line breaks — to `current`. Each tag is\n * tidied; blank parts are ignored; a tag already on the list (ignoring case) is not added twice, and the list keeps\n * its first spelling, as the service does. A tag that is too long, has a control character, or would go past\n * MAX_REQUIRED_SKILLS stays in `rest` with the reason in `problem`.\n */\nexport function addSkillTags(current: readonly string[], typed: string): AddSkillTagsResult {\n const skills = [...current];\n const keys = new Set(current.map(skillKey));\n const kept: string[] = [];\n let problem: SkillTagProblem | null = null;\n for (const part of typed.split(SEPARATORS)) {\n const tag = tidySkill(part);\n if (!tag) continue;\n if (hasControlCharacter(tag)) {\n if (!problem) problem = { kind: 'invalid' };\n kept.push(tag);\n continue;\n }\n if ([...tag].length > MAX_SKILL_LENGTH) {\n if (!problem) problem = { kind: 'tooLong', max: MAX_SKILL_LENGTH };\n kept.push(tag);\n continue;\n }\n const key = tag.toLowerCase();\n if (keys.has(key)) {\n if (!problem) {\n problem = { kind: 'duplicate', skill: skills.find((s) => skillKey(s) === key) ?? tag };\n }\n continue;\n }\n if (skills.length >= MAX_REQUIRED_SKILLS) {\n if (!problem) problem = { kind: 'tooMany', max: MAX_REQUIRED_SKILLS };\n kept.push(tag);\n continue;\n }\n keys.add(key);\n skills.push(tag);\n }\n return { skills, rest: kept.join(', '), problem };\n}\n\n/** `current` without the tag at `index`. */\nexport function removeSkillAt(current: readonly string[], index: number): string[] {\n return current.filter((_, i) => i !== index);\n}\n\n/** Same tags in the same order and spelling: what \"unchanged\" means for a save that sends only changed fields. */\nexport function sameSkillList(a: readonly string[], b: readonly string[]): boolean {\n return a.length === b.length && a.every((skill, i) => skill === b[i]);\n}\n\n/**\n * The service's own reason when a write was refused because of `requiredSkills` (BAD_USER_INPUT tagged\n * `extensions.field: requiredSkills`, e.g. \"A work item can require at most 20 skills\"), or null for any other\n * failure — which the caller shows the way it shows every other refusal.\n */\nexport function requiredSkillsRefusal(error: unknown): string | null {\n if (!CombinedGraphQLErrors.is(error)) return null;\n const refusal = error.errors.find((e) => e.extensions?.field === 'requiredSkills');\n return refusal?.message || null;\n}\n\n// ─── Coverage (planmagnetSkillCoverage) ──────────────────────────────────────\n\nexport type SkillCoverageStatus = 'UNCOVERED' | 'UNKNOWN' | 'COVERED' | 'NO_DEMAND';\n\n/** UNCOVERED first, then UNKNOWN, COVERED and NO_DEMAND — the order the service documents. */\nconst STATUS_ORDER: Record<SkillCoverageStatus, number> = {\n UNCOVERED: 0,\n UNKNOWN: 1,\n COVERED: 2,\n NO_DEMAND: 3,\n};\n\nfunction statusRank(status: string): number {\n return (STATUS_ORDER as Record<string, number>)[status] ?? Object.keys(STATUS_ORDER).length;\n}\n\n/**\n * Rows in status order (UNCOVERED first), keeping the service's order within a status (most open work first, then\n * by skill). The service already sends them this way; sorting here keeps \"uncovered first\" true on screen even if\n * that changes. A status this client does not know yet goes last rather than disappearing.\n */\nexport function orderCoverage<T extends { status: string }>(rows: readonly T[]): T[] {\n return rows\n .map((row, index) => ({ row, index }))\n .sort((a, b) => statusRank(a.row.status) - statusRank(b.row.status) || a.index - b.index)\n .map(({ row }) => row);\n}\n\n/** How many rows have each status. */\nexport function coverageCounts(\n rows: readonly { status: string }[]\n): Record<SkillCoverageStatus, number> {\n const counts: Record<SkillCoverageStatus, number> = {\n UNCOVERED: 0,\n UNKNOWN: 0,\n COVERED: 0,\n NO_DEMAND: 0,\n };\n for (const row of rows) {\n if (row.status in counts) counts[row.status as SkillCoverageStatus] += 1;\n }\n return counts;\n}\n\nexport type CoverageBadgeVariant = 'destructive' | 'warning' | 'success' | 'secondary' | 'outline';\n\n/**\n * Severity, not identity: uncovered demand is the problem, unknown needs a look, covered is fine, no demand is\n * neutral.\n */\nexport function coverageBadgeVariant(status: string): CoverageBadgeVariant {\n switch (status) {\n case 'UNCOVERED':\n return 'destructive';\n case 'UNKNOWN':\n return 'warning';\n case 'COVERED':\n return 'success';\n case 'NO_DEMAND':\n return 'secondary';\n default:\n return 'outline';\n }\n}\n\n/**\n * The filter for planmagnetSkillCoverage with only the ids actually chosen: an empty id is left out (the service\n * answers NOT_FOUND for an id that is not a live project / team / sprint of the workspace).\n */\nexport function coverageFilter(filter: {\n projectId?: string | null;\n teamId?: string | null;\n sprintId?: string | null;\n}): { projectId?: string; teamId?: string; sprintId?: string } {\n const out: { projectId?: string; teamId?: string; sprintId?: string } = {};\n if (filter.projectId) out.projectId = filter.projectId;\n if (filter.teamId) out.teamId = filter.teamId;\n if (filter.sprintId) out.sprintId = filter.sprintId;\n return out;\n}\n\n// ─── CrewFoundry links ───────────────────────────────────────────────────────\n\n/**\n * CrewFoundry's app in the same environment as this page: alpha shells (`alphaapp.*`) link to alpha, the local dev\n * proxy (`*.local.burdenoff.com`, localhost) to the local CrewFoundry shell, everything else to production.\n */\nexport function crewFoundryOrigin(hostname: string): string {\n const host = hostname.toLowerCase();\n if (host.startsWith('alphaapp.') || host.startsWith('alpha.')) {\n return 'https://alphaapp.crewfoundry.com';\n }\n if (\n host === 'localhost' ||\n host === '127.0.0.1' ||\n host.endsWith('.localhost') ||\n host.endsWith('.local.burdenoff.com')\n ) {\n return 'https://crewfoundry.local.burdenoff.com';\n }\n return 'https://app.crewfoundry.com';\n}\n\n/**\n * A CrewFoundry employee's profile, where their skills are edited (CrewFoundry mounts its pages under\n * /crewfoundry).\n */\nexport function crewFoundryEmployeeUrl(employeeId: string, hostname: string): string {\n return `${crewFoundryOrigin(hostname)}/crewfoundry/hr/employee-profile-page?id=${encodeURIComponent(employeeId)}`;\n}\n\n/** CrewFoundry's employee list. */\nexport function crewFoundryEmployeesUrl(hostname: string): string {\n return `${crewFoundryOrigin(hostname)}/crewfoundry/hr/employees-page`;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface RequiredSkillsDraft {
|
|
2
|
+
/** What the editor shows: the user's edits, else the saved list. */
|
|
3
|
+
value: string[];
|
|
4
|
+
setValue: (next: string[]) => void;
|
|
5
|
+
/** The saved list, or null until it has loaded (or when it could not be loaded). */
|
|
6
|
+
saved: string[] | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
/** Why the saved list could not be loaded, or null. */
|
|
9
|
+
loadError: string | null;
|
|
10
|
+
/**
|
|
11
|
+
* The list to send as `requiredSkills`, or undefined when it is unchanged — or not loaded: a list the user never
|
|
12
|
+
* saw is never sent, so a failed read can't clear the item's skills.
|
|
13
|
+
*/
|
|
14
|
+
changedValue: string[] | undefined;
|
|
15
|
+
/** The service's refusal of the last save, shown on the field until the list changes. */
|
|
16
|
+
serverError: string | null;
|
|
17
|
+
setServerError: (message: string | null) => void;
|
|
18
|
+
/** Drop the user's edits so the editor follows the saved list again (after a successful save). */
|
|
19
|
+
reset: () => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The edit page's required-skills state (ANR-07, ANR-28): the saved list comes from the block's own query
|
|
23
|
+
* (`WorkItemRequiredSkills`, the PRM-01 rule — a field the gateway does not serve must not blank the whole edit
|
|
24
|
+
* page), and the user's edits are kept apart from it, so the save sends `requiredSkills` only when it changed.
|
|
25
|
+
* Add 'WorkItemRequiredSkills' to the save's refetchQueries so `saved` is fresh before `reset()`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useRequiredSkillsDraft(workItemId: string, skip?: boolean): RequiredSkillsDraft;
|
|
28
|
+
//# sourceMappingURL=useRequiredSkillsDraft.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRequiredSkillsDraft.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/skills/useRequiredSkillsDraft.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACnC,oFAAoF;IACpF,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,uDAAuD;IACvD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;OAGG;IACH,YAAY,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IACnC,yFAAyF;IACzF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,cAAc,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,kGAAkG;IAClG,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,UAAQ,GAAG,mBAAmB,CA2B5F"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sameSkillList as e } from "./skillTags.js";
|
|
2
|
+
import { WORK_ITEM_REQUIRED_SKILLS as t } from "./skillOperations.js";
|
|
3
|
+
import { useState as n } from "react";
|
|
4
|
+
import { useQuery as r } from "@apollo/client/react";
|
|
5
|
+
//#region src/planmagnet/skills/useRequiredSkillsDraft.ts
|
|
6
|
+
function i(i, a = !1) {
|
|
7
|
+
let { data: o, loading: s, error: c } = r(t, {
|
|
8
|
+
variables: { id: i },
|
|
9
|
+
skip: a || !i
|
|
10
|
+
}), l = o?.planmagnetWorkItem?.requiredSkills ?? null, [u, d] = n(null), [f, p] = n(null);
|
|
11
|
+
return {
|
|
12
|
+
value: u ?? l ?? [],
|
|
13
|
+
setValue: (e) => {
|
|
14
|
+
d(e), p(null);
|
|
15
|
+
},
|
|
16
|
+
saved: l,
|
|
17
|
+
loading: s && !o,
|
|
18
|
+
loadError: l === null && c ? c.message : null,
|
|
19
|
+
changedValue: l !== null && u !== null && !e(u, l) ? u : void 0,
|
|
20
|
+
serverError: f,
|
|
21
|
+
setServerError: p,
|
|
22
|
+
reset: () => {
|
|
23
|
+
d(null), p(null);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { i as useRequiredSkillsDraft };
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=useRequiredSkillsDraft.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRequiredSkillsDraft.js","names":[],"sources":["../../../src/planmagnet/skills/useRequiredSkillsDraft.ts"],"sourcesContent":["import { useState } from 'react';\nimport { useQuery } from '@apollo/client/react';\nimport { WORK_ITEM_REQUIRED_SKILLS, type WorkItemRequiredSkillsData } from './skillOperations';\nimport { sameSkillList } from './skillTags';\n\nexport interface RequiredSkillsDraft {\n /** What the editor shows: the user's edits, else the saved list. */\n value: string[];\n setValue: (next: string[]) => void;\n /** The saved list, or null until it has loaded (or when it could not be loaded). */\n saved: string[] | null;\n loading: boolean;\n /** Why the saved list could not be loaded, or null. */\n loadError: string | null;\n /**\n * The list to send as `requiredSkills`, or undefined when it is unchanged — or not loaded: a list the user never\n * saw is never sent, so a failed read can't clear the item's skills.\n */\n changedValue: string[] | undefined;\n /** The service's refusal of the last save, shown on the field until the list changes. */\n serverError: string | null;\n setServerError: (message: string | null) => void;\n /** Drop the user's edits so the editor follows the saved list again (after a successful save). */\n reset: () => void;\n}\n\n/**\n * The edit page's required-skills state (ANR-07, ANR-28): the saved list comes from the block's own query\n * (`WorkItemRequiredSkills`, the PRM-01 rule — a field the gateway does not serve must not blank the whole edit\n * page), and the user's edits are kept apart from it, so the save sends `requiredSkills` only when it changed.\n * Add 'WorkItemRequiredSkills' to the save's refetchQueries so `saved` is fresh before `reset()`.\n */\nexport function useRequiredSkillsDraft(workItemId: string, skip = false): RequiredSkillsDraft {\n const { data, loading, error } = useQuery<WorkItemRequiredSkillsData>(WORK_ITEM_REQUIRED_SKILLS, {\n variables: { id: workItemId },\n skip: skip || !workItemId,\n });\n const saved = data?.planmagnetWorkItem?.requiredSkills ?? null;\n const [draft, setDraft] = useState<string[] | null>(null);\n const [serverError, setServerError] = useState<string | null>(null);\n\n return {\n value: draft ?? saved ?? [],\n setValue: (next) => {\n setDraft(next);\n setServerError(null);\n },\n saved,\n loading: loading && !data,\n loadError: saved === null && error ? error.message : null,\n changedValue:\n saved !== null && draft !== null && !sameSkillList(draft, saved) ? draft : undefined,\n serverError,\n setServerError,\n reset: () => {\n setDraft(null);\n setServerError(null);\n },\n };\n}\n"],"mappings":";;;;;AAgCA,SAAgB,EAAuB,GAAoB,IAAO,IAA4B;CAC5F,IAAM,EAAE,SAAM,YAAS,aAAU,EAAqC,GAA2B;EAC/F,WAAW,EAAE,IAAI,GAAY;EAC7B,MAAM,KAAQ,CAAC;EAChB,CAAC,EACI,IAAQ,GAAM,oBAAoB,kBAAkB,MACpD,CAAC,GAAO,KAAY,EAA0B,KAAK,EACnD,CAAC,GAAa,KAAkB,EAAwB,KAAK;AAEnE,QAAO;EACL,OAAO,KAAS,KAAS,EAAE;EAC3B,WAAW,MAAS;AAElB,GADA,EAAS,EAAK,EACd,EAAe,KAAK;;EAEtB;EACA,SAAS,KAAW,CAAC;EACrB,WAAW,MAAU,QAAQ,IAAQ,EAAM,UAAU;EACrD,cACE,MAAU,QAAQ,MAAU,QAAQ,CAAC,EAAc,GAAO,EAAM,GAAG,IAAQ,KAAA;EAC7E;EACA;EACA,aAAa;AAEX,GADA,EAAS,KAAK,EACd,EAAe,KAAK;;EAEvB"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { InvitationEmailFields } from './invitationEmail';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The workspace's pending invitations on the Invite page (BOFF-8866): who was invited, with which access, whether
|
|
5
|
+
* the invitation email actually went out (and when it was last tried), when the invitation expires, and Resend /
|
|
6
|
+
* Cancel. `workspaceInvitations` and the two writes read the workspace from the gateway's x-workspace-id header.
|
|
7
|
+
* A write's outcome is shown above the list, so a row that leaves the list on the refresh does not take its message
|
|
8
|
+
* with it.
|
|
9
|
+
*/
|
|
10
|
+
/** Operation name of the list, for `refetchQueries` of every write that changes it. */
|
|
11
|
+
export declare const PENDING_INVITATIONS_QUERY = "PlanmagnetPendingInvitations";
|
|
12
|
+
/** Sends the invitation email again (and renews the invitation's expiry); answers with the new email outcome. */
|
|
13
|
+
export declare const RESEND_INVITATION: import('graphql').DocumentNode;
|
|
14
|
+
export interface InvitationEmailResult extends InvitationEmailFields {
|
|
15
|
+
id: string;
|
|
16
|
+
status: string;
|
|
17
|
+
expiresAt: string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function PendingInvitations({ workspaceId, currentUserId, roleNames, }: {
|
|
20
|
+
/** The workspace the list is for. The query is scoped by a request header, so callers key this component on it. */
|
|
21
|
+
workspaceId?: string | null;
|
|
22
|
+
/** Cancelling is the inviter's own action; other people's invitations are listed without it. */
|
|
23
|
+
currentUserId?: string | null;
|
|
24
|
+
/** PlanMagnet access-level names by role id, as the page already loaded them. */
|
|
25
|
+
roleNames: ReadonlyMap<string, string>;
|
|
26
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
27
|
+
//# sourceMappingURL=PendingInvitations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PendingInvitations.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/team/PendingInvitations.tsx"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,qBAAqB,EAC3B,MAAM,mBAAmB,CAAC;AAE3B;;;;;;GAMG;AAEH,uFAAuF;AACvF,eAAO,MAAM,yBAAyB,iCAAiC,CAAC;AAwBxE,iHAAiH;AACjH,eAAO,MAAM,iBAAiB,gCAW7B,CAAC;AAWF,MAAM,WAAW,qBAAsB,SAAQ,qBAAqB;IAClE,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAyBD,wBAAgB,kBAAkB,CAAC,EACjC,WAAW,EACX,aAAa,EACb,SAAS,GACV,EAAE;IACD,mHAAmH;IACnH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,gGAAgG;IAChG,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,iFAAiF;IACjF,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACxC,2CAyRA"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { useTr as e } from "../../shared/hooks/useTr.js";
|
|
2
|
+
import { isForbidden as t } from "../utils/graphqlErrors.js";
|
|
3
|
+
import { throwOnError as n } from "../utils/mutationResult.js";
|
|
4
|
+
import { ConfirmButton as r } from "../controls/ConfirmButton.js";
|
|
5
|
+
import { refusalMessage as i } from "../product/refusal.js";
|
|
6
|
+
import { invitationEmailBadgeVariant as a, invitationEmailReason as o, invitationEmailSent as s, invitationEmailStatusLabel as c, invitationRoleNames as l, parseInvitationDate as u } from "./invitationEmail.js";
|
|
7
|
+
import { useState as d } from "react";
|
|
8
|
+
import { Mail as f, RefreshCw as p, X as m } from "lucide-react";
|
|
9
|
+
import { gql as h } from "@apollo/client";
|
|
10
|
+
import { useMutation as g, useQuery as _ } from "@apollo/client/react";
|
|
11
|
+
import { jsx as v, jsxs as y } from "react/jsx-runtime";
|
|
12
|
+
import { Badge as b, Button as x, Card as S, CardContent as C, CardHeader as w, CardTitle as T } from "@burdenoff/fe-libs/ui";
|
|
13
|
+
import { clearGraphqlFetchResponseCache as E } from "@burdenoff/fe-libs/shared/graphql";
|
|
14
|
+
//#region src/planmagnet/team/PendingInvitations.tsx
|
|
15
|
+
var D = "PlanmagnetPendingInvitations", O = 50, k = h`
|
|
16
|
+
query PlanmagnetPendingInvitations($pagination: PaginationInput) {
|
|
17
|
+
workspaceInvitations(status: PENDING, pagination: $pagination) {
|
|
18
|
+
items {
|
|
19
|
+
id
|
|
20
|
+
email
|
|
21
|
+
status
|
|
22
|
+
roleIds
|
|
23
|
+
invitedById
|
|
24
|
+
expiresAt
|
|
25
|
+
emailStatus
|
|
26
|
+
emailLastAttemptAt
|
|
27
|
+
emailErrorCode
|
|
28
|
+
}
|
|
29
|
+
total
|
|
30
|
+
hasMore
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
`, A = h`
|
|
34
|
+
mutation PlanmagnetResendInvitation($id: ID!) {
|
|
35
|
+
resendInvitation(id: $id) {
|
|
36
|
+
id
|
|
37
|
+
status
|
|
38
|
+
expiresAt
|
|
39
|
+
emailStatus
|
|
40
|
+
emailLastAttemptAt
|
|
41
|
+
emailErrorCode
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
`, j = h`
|
|
45
|
+
mutation PlanmagnetCancelInvitation($id: ID!) {
|
|
46
|
+
cancelInvitation(id: $id) {
|
|
47
|
+
id
|
|
48
|
+
status
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`, M = {
|
|
52
|
+
refetchQueries: [D],
|
|
53
|
+
awaitRefetchQueries: !0
|
|
54
|
+
}, N = {
|
|
55
|
+
success: "text-status-success-text",
|
|
56
|
+
warning: "text-status-warning-text",
|
|
57
|
+
error: "text-destructive"
|
|
58
|
+
}, P = (e) => u(e)?.toLocaleString() ?? null;
|
|
59
|
+
function F({ workspaceId: h, currentUserId: D, roleNames: F }) {
|
|
60
|
+
let I = e(), { data: L, loading: R, error: z, refetch: B } = _(k, {
|
|
61
|
+
variables: { pagination: { limit: O } },
|
|
62
|
+
skip: !h,
|
|
63
|
+
fetchPolicy: "network-only"
|
|
64
|
+
}), [V] = g(A, M), [H] = g(j, M), [U, W] = d(null), [G, K] = d(null), [q] = d(() => Date.now()), J = I("planmagnet.invite.pending.forbidden", "Your access level does not allow managing invitations in this workspace."), Y = () => {
|
|
65
|
+
E(), B();
|
|
66
|
+
}, X = async (e) => {
|
|
67
|
+
W(e.id), K(null);
|
|
68
|
+
try {
|
|
69
|
+
let t = (await n(V({ variables: { id: e.id } }))).data?.resendInvitation;
|
|
70
|
+
if (s(t)) K({
|
|
71
|
+
tone: "success",
|
|
72
|
+
text: I("planmagnet.invite.pending.resent", "Invitation email sent again to {{email}}.", { email: e.email })
|
|
73
|
+
});
|
|
74
|
+
else {
|
|
75
|
+
let n = o(t?.emailErrorCode);
|
|
76
|
+
K({
|
|
77
|
+
tone: "warning",
|
|
78
|
+
text: I("planmagnet.invite.pending.resendNotSent", "The invitation to {{email}} is still pending, but the email was not sent. {{reason}}", {
|
|
79
|
+
email: e.email,
|
|
80
|
+
reason: I(n.key, n.fallback)
|
|
81
|
+
})
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
} catch (t) {
|
|
85
|
+
K({
|
|
86
|
+
tone: "error",
|
|
87
|
+
text: I("planmagnet.invite.pending.resendFailed", "The invitation to {{email}} could not be resent: {{reason}}", {
|
|
88
|
+
email: e.email,
|
|
89
|
+
reason: i(t, I("planmagnet.invite.pending.resendFailedFallback", "Try again."), J)
|
|
90
|
+
})
|
|
91
|
+
}), Y();
|
|
92
|
+
} finally {
|
|
93
|
+
W(null);
|
|
94
|
+
}
|
|
95
|
+
}, Z = async (e) => {
|
|
96
|
+
W(e.id), K(null);
|
|
97
|
+
try {
|
|
98
|
+
await n(H({ variables: { id: e.id } })), K({
|
|
99
|
+
tone: "success",
|
|
100
|
+
text: I("planmagnet.invite.pending.cancelled", "The invitation to {{email}} was cancelled.", { email: e.email })
|
|
101
|
+
});
|
|
102
|
+
} catch (t) {
|
|
103
|
+
K({
|
|
104
|
+
tone: "error",
|
|
105
|
+
text: I("planmagnet.invite.pending.cancelFailed", "The invitation to {{email}} could not be cancelled: {{reason}}", {
|
|
106
|
+
email: e.email,
|
|
107
|
+
reason: i(t, I("planmagnet.invite.pending.cancelFailedFallback", "Try again."), J)
|
|
108
|
+
})
|
|
109
|
+
}), Y();
|
|
110
|
+
} finally {
|
|
111
|
+
W(null);
|
|
112
|
+
}
|
|
113
|
+
}, Q = L?.workspaceInvitations, $ = Q?.items ?? [], ee = (e) => {
|
|
114
|
+
let { names: t, unknown: n } = l(e, F), r = [...t];
|
|
115
|
+
return n > 0 && r.push(I("planmagnet.invite.pending.otherRoles", "{{count}} other access level(s)", { count: n })), r.length > 0 ? r.join(", ") : I("planmagnet.invite.pending.noRoles", "No access level");
|
|
116
|
+
};
|
|
117
|
+
return /* @__PURE__ */ y(S, {
|
|
118
|
+
"data-testid": "pending-invitations",
|
|
119
|
+
children: [/* @__PURE__ */ v(w, { children: /* @__PURE__ */ y(T, {
|
|
120
|
+
className: "flex items-center gap-2",
|
|
121
|
+
children: [/* @__PURE__ */ v(f, { className: "h-4 w-4" }), I("planmagnet.invite.pending.title", "Pending invitations")]
|
|
122
|
+
}) }), /* @__PURE__ */ y(C, {
|
|
123
|
+
className: "space-y-3",
|
|
124
|
+
children: [
|
|
125
|
+
G && /* @__PURE__ */ v("p", {
|
|
126
|
+
role: G.tone === "error" ? "alert" : "status",
|
|
127
|
+
className: `text-sm ${N[G.tone]}`,
|
|
128
|
+
"data-testid": "pending-invitations-notice",
|
|
129
|
+
children: G.text
|
|
130
|
+
}),
|
|
131
|
+
R && !L ? /* @__PURE__ */ v("p", {
|
|
132
|
+
className: "text-sm text-muted-foreground",
|
|
133
|
+
children: I("planmagnet.invite.pending.loading", "Loading pending invitations…")
|
|
134
|
+
}) : z && !L ? /* @__PURE__ */ v("p", {
|
|
135
|
+
role: "alert",
|
|
136
|
+
className: "text-sm text-destructive",
|
|
137
|
+
children: t(z) ? J : I("planmagnet.invite.pending.loadFailed", "Pending invitations could not be loaded: {{reason}}", { reason: z.message })
|
|
138
|
+
}) : $.length === 0 ? /* @__PURE__ */ v("p", {
|
|
139
|
+
className: "text-sm text-muted-foreground",
|
|
140
|
+
children: I("planmagnet.invite.pending.empty", "No invitations are waiting to be accepted.")
|
|
141
|
+
}) : /* @__PURE__ */ v("ul", {
|
|
142
|
+
className: "divide-y divide-border",
|
|
143
|
+
"data-testid": "pending-invitations-list",
|
|
144
|
+
children: $.map((e) => {
|
|
145
|
+
let t = s(e), n = c(e.emailStatus), i = o(e.emailErrorCode), l = P(e.emailLastAttemptAt), d = u(e.expiresAt), f = !!d && d.getTime() <= q, h = U === e.id;
|
|
146
|
+
return /* @__PURE__ */ y("li", {
|
|
147
|
+
className: "flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between",
|
|
148
|
+
"data-testid": "pending-invitation",
|
|
149
|
+
children: [/* @__PURE__ */ y("div", {
|
|
150
|
+
className: "min-w-0 space-y-1",
|
|
151
|
+
children: [
|
|
152
|
+
/* @__PURE__ */ v("p", {
|
|
153
|
+
className: "truncate text-sm font-medium",
|
|
154
|
+
children: e.email
|
|
155
|
+
}),
|
|
156
|
+
/* @__PURE__ */ v("p", {
|
|
157
|
+
className: "text-xs text-muted-foreground",
|
|
158
|
+
children: ee(e.roleIds)
|
|
159
|
+
}),
|
|
160
|
+
/* @__PURE__ */ y("div", {
|
|
161
|
+
className: "flex flex-wrap items-center gap-2 text-xs text-muted-foreground",
|
|
162
|
+
children: [
|
|
163
|
+
/* @__PURE__ */ v(b, {
|
|
164
|
+
variant: a(e.emailStatus),
|
|
165
|
+
"data-testid": "pending-invitation-email-status",
|
|
166
|
+
children: I(n.key, n.fallback)
|
|
167
|
+
}),
|
|
168
|
+
l && /* @__PURE__ */ v("span", { children: I("planmagnet.invite.pending.lastAttempt", "Last tried {{when}}", { when: l }) }),
|
|
169
|
+
d && /* @__PURE__ */ v("span", {
|
|
170
|
+
className: f ? "text-status-warning-text" : void 0,
|
|
171
|
+
children: f ? I("planmagnet.invite.pending.expired", "Expired {{when}}", { when: d.toLocaleString() }) : I("planmagnet.invite.pending.expires", "Expires {{when}}", { when: d.toLocaleString() })
|
|
172
|
+
})
|
|
173
|
+
]
|
|
174
|
+
}),
|
|
175
|
+
!t && e.emailStatus && /* @__PURE__ */ v("p", {
|
|
176
|
+
className: "text-xs text-status-warning-text",
|
|
177
|
+
children: I(i.key, i.fallback)
|
|
178
|
+
})
|
|
179
|
+
]
|
|
180
|
+
}), /* @__PURE__ */ y("div", {
|
|
181
|
+
className: "flex shrink-0 gap-2",
|
|
182
|
+
children: [/* @__PURE__ */ y(x, {
|
|
183
|
+
size: "sm",
|
|
184
|
+
variant: "outline",
|
|
185
|
+
disabled: U !== null,
|
|
186
|
+
onClick: () => void X(e),
|
|
187
|
+
"data-testid": "pending-invitation-resend",
|
|
188
|
+
children: [/* @__PURE__ */ v(p, { className: "mr-1 h-4 w-4" }), h ? I("planmagnet.invite.pending.working", "Working…") : I("planmagnet.invite.pending.resend", "Resend")]
|
|
189
|
+
}), e.invitedById === D && /* @__PURE__ */ v(r, {
|
|
190
|
+
label: I("planmagnet.invite.pending.cancel", "Cancel"),
|
|
191
|
+
variant: "outline",
|
|
192
|
+
icon: /* @__PURE__ */ v(m, { className: "mr-1 h-4 w-4" }),
|
|
193
|
+
testId: "pending-invitation-cancel",
|
|
194
|
+
title: I("planmagnet.invite.pending.confirmCancel.title", "Cancel the invitation to {{email}}?", { email: e.email }),
|
|
195
|
+
description: I("planmagnet.invite.pending.confirmCancel.description", "Its link stops working and they will not join this workspace from it. You can invite them again later."),
|
|
196
|
+
confirmLabel: I("planmagnet.invite.pending.confirmCancel.confirm", "Cancel invitation"),
|
|
197
|
+
cancelLabel: I("planmagnet.invite.pending.confirmCancel.keep", "Keep it"),
|
|
198
|
+
destructive: !0,
|
|
199
|
+
disabled: U !== null,
|
|
200
|
+
onConfirm: () => void Z(e)
|
|
201
|
+
})]
|
|
202
|
+
})]
|
|
203
|
+
}, e.id);
|
|
204
|
+
})
|
|
205
|
+
}),
|
|
206
|
+
Q?.hasMore && /* @__PURE__ */ v("p", {
|
|
207
|
+
className: "text-xs text-muted-foreground",
|
|
208
|
+
children: I("planmagnet.invite.pending.truncated", "Showing the first {{shown}} of {{total}} pending invitations.", {
|
|
209
|
+
shown: $.length,
|
|
210
|
+
total: Q.total
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
]
|
|
214
|
+
})]
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
export { D as PENDING_INVITATIONS_QUERY, F as PendingInvitations, A as RESEND_INVITATION };
|
|
219
|
+
|
|
220
|
+
//# sourceMappingURL=PendingInvitations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PendingInvitations.js","names":[],"sources":["../../../src/planmagnet/team/PendingInvitations.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport { clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql';\nimport { Badge, Button, Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { Mail, RefreshCw, X } from 'lucide-react';\nimport { useTr } from '../../shared/hooks/useTr';\nimport { ConfirmButton } from '../controls/ConfirmButton';\nimport { refusalMessage } from '../product/refusal';\nimport { isForbidden } from '../utils/graphqlErrors';\nimport { throwOnError } from '../utils/mutationResult';\nimport {\n invitationEmailBadgeVariant,\n invitationEmailReason,\n invitationEmailSent,\n invitationEmailStatusLabel,\n invitationRoleNames,\n parseInvitationDate,\n type InvitationEmailFields,\n} from './invitationEmail';\n\n/**\n * The workspace's pending invitations on the Invite page (BOFF-8866): who was invited, with which access, whether\n * the invitation email actually went out (and when it was last tried), when the invitation expires, and Resend /\n * Cancel. `workspaceInvitations` and the two writes read the workspace from the gateway's x-workspace-id header.\n * A write's outcome is shown above the list, so a row that leaves the list on the refresh does not take its message\n * with it.\n */\n\n/** Operation name of the list, for `refetchQueries` of every write that changes it. */\nexport const PENDING_INVITATIONS_QUERY = 'PlanmagnetPendingInvitations';\n\nconst PAGE_LIMIT = 50;\n\nconst PENDING_INVITATIONS = gql`\n query PlanmagnetPendingInvitations($pagination: PaginationInput) {\n workspaceInvitations(status: PENDING, pagination: $pagination) {\n items {\n id\n email\n status\n roleIds\n invitedById\n expiresAt\n emailStatus\n emailLastAttemptAt\n emailErrorCode\n }\n total\n hasMore\n }\n }\n`;\n\n/** Sends the invitation email again (and renews the invitation's expiry); answers with the new email outcome. */\nexport const RESEND_INVITATION = gql`\n mutation PlanmagnetResendInvitation($id: ID!) {\n resendInvitation(id: $id) {\n id\n status\n expiresAt\n emailStatus\n emailLastAttemptAt\n emailErrorCode\n }\n }\n`;\n\nconst CANCEL_INVITATION = gql`\n mutation PlanmagnetCancelInvitation($id: ID!) {\n cancelInvitation(id: $id) {\n id\n status\n }\n }\n`;\n\nexport interface InvitationEmailResult extends InvitationEmailFields {\n id: string;\n status: string;\n expiresAt: string | null;\n}\n\ninterface PendingInvitation extends InvitationEmailResult {\n email: string;\n roleIds: string[];\n invitedById: string | null;\n}\n\ninterface PendingInvitationsData {\n workspaceInvitations: { items: PendingInvitation[]; total: number; hasMore: boolean };\n}\n\nconst WRITE_OPTIONS = { refetchQueries: [PENDING_INVITATIONS_QUERY], awaitRefetchQueries: true };\n\ntype Notice = { tone: 'success' | 'warning' | 'error'; text: string };\n\nconst NOTICE_CLASS: Record<Notice['tone'], string> = {\n success: 'text-status-success-text',\n warning: 'text-status-warning-text',\n error: 'text-destructive',\n};\n\nconst formatDate = (value: string | null | undefined): string | null =>\n parseInvitationDate(value)?.toLocaleString() ?? null;\n\nexport function PendingInvitations({\n workspaceId,\n currentUserId,\n roleNames,\n}: {\n /** The workspace the list is for. The query is scoped by a request header, so callers key this component on it. */\n workspaceId?: string | null;\n /** Cancelling is the inviter's own action; other people's invitations are listed without it. */\n currentUserId?: string | null;\n /** PlanMagnet access-level names by role id, as the page already loaded them. */\n roleNames: ReadonlyMap<string, string>;\n}) {\n const tr = useTr();\n const { data, loading, error, refetch } = useQuery<PendingInvitationsData>(PENDING_INVITATIONS, {\n variables: { pagination: { limit: PAGE_LIMIT } },\n skip: !workspaceId,\n // Header-scoped (x-workspace-id), so its cache key is the same in every workspace: never render a cached\n // result from another workspace's visit, always wait for this workspace's answer.\n fetchPolicy: 'network-only',\n });\n const [resend] = useMutation<{ resendInvitation: InvitationEmailResult }>(\n RESEND_INVITATION,\n WRITE_OPTIONS\n );\n const [cancel] = useMutation(CANCEL_INVITATION, WRITE_OPTIONS);\n const [busyId, setBusyId] = useState<string | null>(null);\n const [notice, setNotice] = useState<Notice | null>(null);\n // The clock the expiry badge is judged against, read once per page visit (render must stay pure).\n const [now] = useState(() => Date.now());\n\n const forbidden = tr(\n 'planmagnet.invite.pending.forbidden',\n 'Your access level does not allow managing invitations in this workspace.'\n );\n\n // A refused write (e.g. the invitation was accepted or cancelled elsewhere) leaves the 15 s fetch replay in place;\n // drop it so the re-read shows the invitation as it is now.\n const reloadAfterRefusal = () => {\n clearGraphqlFetchResponseCache();\n void refetch();\n };\n\n const onResend = async (invitation: PendingInvitation) => {\n setBusyId(invitation.id);\n setNotice(null);\n try {\n const result = await throwOnError(resend({ variables: { id: invitation.id } }));\n const updated = result.data?.resendInvitation;\n if (invitationEmailSent(updated)) {\n setNotice({\n tone: 'success',\n text: tr(\n 'planmagnet.invite.pending.resent',\n 'Invitation email sent again to {{email}}.',\n {\n email: invitation.email,\n }\n ),\n });\n } else {\n const reason = invitationEmailReason(updated?.emailErrorCode);\n setNotice({\n tone: 'warning',\n text: tr(\n 'planmagnet.invite.pending.resendNotSent',\n 'The invitation to {{email}} is still pending, but the email was not sent. {{reason}}',\n { email: invitation.email, reason: tr(reason.key, reason.fallback) }\n ),\n });\n }\n } catch (e) {\n setNotice({\n tone: 'error',\n text: tr(\n 'planmagnet.invite.pending.resendFailed',\n 'The invitation to {{email}} could not be resent: {{reason}}',\n {\n email: invitation.email,\n reason: refusalMessage(\n e,\n tr('planmagnet.invite.pending.resendFailedFallback', 'Try again.'),\n forbidden\n ),\n }\n ),\n });\n reloadAfterRefusal();\n } finally {\n setBusyId(null);\n }\n };\n\n const onCancel = async (invitation: PendingInvitation) => {\n setBusyId(invitation.id);\n setNotice(null);\n try {\n await throwOnError(cancel({ variables: { id: invitation.id } }));\n setNotice({\n tone: 'success',\n text: tr(\n 'planmagnet.invite.pending.cancelled',\n 'The invitation to {{email}} was cancelled.',\n {\n email: invitation.email,\n }\n ),\n });\n } catch (e) {\n setNotice({\n tone: 'error',\n text: tr(\n 'planmagnet.invite.pending.cancelFailed',\n 'The invitation to {{email}} could not be cancelled: {{reason}}',\n {\n email: invitation.email,\n reason: refusalMessage(\n e,\n tr('planmagnet.invite.pending.cancelFailedFallback', 'Try again.'),\n forbidden\n ),\n }\n ),\n });\n reloadAfterRefusal();\n } finally {\n setBusyId(null);\n }\n };\n\n const list = data?.workspaceInvitations;\n const items = list?.items ?? [];\n\n const rolesText = (roleIds: string[]): string => {\n const { names, unknown } = invitationRoleNames(roleIds, roleNames);\n const parts = [...names];\n if (unknown > 0) {\n parts.push(\n tr('planmagnet.invite.pending.otherRoles', '{{count}} other access level(s)', {\n count: unknown,\n })\n );\n }\n return parts.length > 0\n ? parts.join(', ')\n : tr('planmagnet.invite.pending.noRoles', 'No access level');\n };\n\n return (\n <Card data-testid=\"pending-invitations\">\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Mail className=\"h-4 w-4\" />\n {tr('planmagnet.invite.pending.title', 'Pending invitations')}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {notice && (\n <p\n role={notice.tone === 'error' ? 'alert' : 'status'}\n className={`text-sm ${NOTICE_CLASS[notice.tone]}`}\n data-testid=\"pending-invitations-notice\"\n >\n {notice.text}\n </p>\n )}\n\n {loading && !data ? (\n <p className=\"text-sm text-muted-foreground\">\n {tr('planmagnet.invite.pending.loading', 'Loading pending invitations…')}\n </p>\n ) : error && !data ? (\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {isForbidden(error)\n ? forbidden\n : tr(\n 'planmagnet.invite.pending.loadFailed',\n 'Pending invitations could not be loaded: {{reason}}',\n { reason: error.message }\n )}\n </p>\n ) : items.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">\n {tr('planmagnet.invite.pending.empty', 'No invitations are waiting to be accepted.')}\n </p>\n ) : (\n <ul className=\"divide-y divide-border\" data-testid=\"pending-invitations-list\">\n {items.map((invitation) => {\n const sent = invitationEmailSent(invitation);\n const status = invitationEmailStatusLabel(invitation.emailStatus);\n const reason = invitationEmailReason(invitation.emailErrorCode);\n const attempted = formatDate(invitation.emailLastAttemptAt);\n const expires = parseInvitationDate(invitation.expiresAt);\n const expired = !!expires && expires.getTime() <= now;\n const busy = busyId === invitation.id;\n return (\n <li\n key={invitation.id}\n className=\"flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between\"\n data-testid=\"pending-invitation\"\n >\n <div className=\"min-w-0 space-y-1\">\n <p className=\"truncate text-sm font-medium\">{invitation.email}</p>\n <p className=\"text-xs text-muted-foreground\">{rolesText(invitation.roleIds)}</p>\n <div className=\"flex flex-wrap items-center gap-2 text-xs text-muted-foreground\">\n <Badge\n variant={invitationEmailBadgeVariant(invitation.emailStatus)}\n data-testid=\"pending-invitation-email-status\"\n >\n {tr(status.key, status.fallback)}\n </Badge>\n {attempted && (\n <span>\n {tr('planmagnet.invite.pending.lastAttempt', 'Last tried {{when}}', {\n when: attempted,\n })}\n </span>\n )}\n {expires && (\n <span className={expired ? 'text-status-warning-text' : undefined}>\n {expired\n ? tr('planmagnet.invite.pending.expired', 'Expired {{when}}', {\n when: expires.toLocaleString(),\n })\n : tr('planmagnet.invite.pending.expires', 'Expires {{when}}', {\n when: expires.toLocaleString(),\n })}\n </span>\n )}\n </div>\n {!sent && invitation.emailStatus && (\n <p className=\"text-xs text-status-warning-text\">\n {tr(reason.key, reason.fallback)}\n </p>\n )}\n </div>\n <div className=\"flex shrink-0 gap-2\">\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={busyId !== null}\n onClick={() => void onResend(invitation)}\n data-testid=\"pending-invitation-resend\"\n >\n <RefreshCw className=\"mr-1 h-4 w-4\" />\n {busy\n ? tr('planmagnet.invite.pending.working', 'Working…')\n : tr('planmagnet.invite.pending.resend', 'Resend')}\n </Button>\n {invitation.invitedById === currentUserId && (\n <ConfirmButton\n label={tr('planmagnet.invite.pending.cancel', 'Cancel')}\n variant=\"outline\"\n icon={<X className=\"mr-1 h-4 w-4\" />}\n testId=\"pending-invitation-cancel\"\n title={tr(\n 'planmagnet.invite.pending.confirmCancel.title',\n 'Cancel the invitation to {{email}}?',\n { email: invitation.email }\n )}\n description={tr(\n 'planmagnet.invite.pending.confirmCancel.description',\n 'Its link stops working and they will not join this workspace from it. You can invite them again later.'\n )}\n confirmLabel={tr(\n 'planmagnet.invite.pending.confirmCancel.confirm',\n 'Cancel invitation'\n )}\n cancelLabel={tr('planmagnet.invite.pending.confirmCancel.keep', 'Keep it')}\n destructive\n disabled={busyId !== null}\n onConfirm={() => void onCancel(invitation)}\n />\n )}\n </div>\n </li>\n );\n })}\n </ul>\n )}\n\n {list?.hasMore && (\n <p className=\"text-xs text-muted-foreground\">\n {tr(\n 'planmagnet.invite.pending.truncated',\n 'Showing the first {{shown}} of {{total}} pending invitations.',\n { shown: items.length, total: list.total }\n )}\n </p>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invitation email outcome (BOFF-8866). The workspace service records what happened to the most recent invitation
|
|
3
|
+
* email on the invitation itself: `emailStatus` (SENT / FAILED / SKIPPED, null before any attempt was recorded),
|
|
4
|
+
* `emailLastAttemptAt`, and `emailErrorCode`, a machine code for a FAILED or SKIPPED outcome. Creating an
|
|
5
|
+
* invitation used to be reported as "Invitation sent" even when no email went out; these helpers turn the recorded
|
|
6
|
+
* outcome into what the invite page says. Each message is an i18n key with its English fallback, for `useTr`.
|
|
7
|
+
*/
|
|
8
|
+
export type InvitationEmailStatus = 'SENT' | 'FAILED' | 'SKIPPED';
|
|
9
|
+
export interface InvitationEmailFields {
|
|
10
|
+
emailStatus?: InvitationEmailStatus | null;
|
|
11
|
+
emailLastAttemptAt?: string | null;
|
|
12
|
+
emailErrorCode?: string | null;
|
|
13
|
+
}
|
|
14
|
+
export interface TrMessage {
|
|
15
|
+
key: string;
|
|
16
|
+
fallback: string;
|
|
17
|
+
}
|
|
18
|
+
/** Only a recorded SENT counts as sent: FAILED, SKIPPED and a missing outcome all mean no email is known to have left. */
|
|
19
|
+
export declare function invitationEmailSent(invitation: InvitationEmailFields | null | undefined): boolean;
|
|
20
|
+
/** Why the email was not sent, worded for the inviter, from the service's machine code. */
|
|
21
|
+
export declare function invitationEmailReason(code: string | null | undefined): TrMessage;
|
|
22
|
+
/** The short label for a pending invitation's email column. */
|
|
23
|
+
export declare function invitationEmailStatusLabel(status: InvitationEmailStatus | null | undefined): TrMessage;
|
|
24
|
+
export declare function invitationEmailBadgeVariant(status: InvitationEmailStatus | null | undefined): 'secondary' | 'destructive' | 'outline';
|
|
25
|
+
/**
|
|
26
|
+
* A DateTime from the gateway as a Date. The scalar is an ISO string, but a timestamp that reaches the client as
|
|
27
|
+
* epoch milliseconds in a string is read too, so a serializer change cannot turn a date into "Invalid Date".
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseInvitationDate(value: string | null | undefined): Date | null;
|
|
30
|
+
/** The names of an invitation's roles that this page knows; ids it cannot name are counted, never shown raw. */
|
|
31
|
+
export declare function invitationRoleNames(roleIds: readonly string[], known: ReadonlyMap<string, string>): {
|
|
32
|
+
names: string[];
|
|
33
|
+
unknown: number;
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=invitationEmail.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invitationEmail.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/team/invitationEmail.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAElE,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAC3C,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,0HAA0H;AAC1H,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,qBAAqB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEjG;AAED,2FAA2F;AAC3F,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CA8BhF;AAED,+DAA+D;AAC/D,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,qBAAqB,GAAG,IAAI,GAAG,SAAS,GAC/C,SAAS,CAWX;AAED,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,qBAAqB,GAAG,IAAI,GAAG,SAAS,GAC/C,WAAW,GAAG,aAAa,GAAG,SAAS,CAIzC;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAIjF;AAED,gHAAgH;AAChH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAStC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/planmagnet/team/invitationEmail.ts
|
|
2
|
+
function e(e) {
|
|
3
|
+
return e?.emailStatus === "SENT";
|
|
4
|
+
}
|
|
5
|
+
function t(e) {
|
|
6
|
+
switch (e) {
|
|
7
|
+
case "RECIPIENT_UNSUBSCRIBED": return {
|
|
8
|
+
key: "planmagnet.invite.email.reason.unsubscribed",
|
|
9
|
+
fallback: "The recipient has opted out of emails."
|
|
10
|
+
};
|
|
11
|
+
case "EMAIL_NOT_CONFIGURED": return {
|
|
12
|
+
key: "planmagnet.invite.email.reason.notConfigured",
|
|
13
|
+
fallback: "Invitation emails are not set up in this environment."
|
|
14
|
+
};
|
|
15
|
+
case "RATE_LIMITED": return {
|
|
16
|
+
key: "planmagnet.invite.email.reason.rateLimited",
|
|
17
|
+
fallback: "Too many emails were sent at once. Resend it in a few minutes."
|
|
18
|
+
};
|
|
19
|
+
case null:
|
|
20
|
+
case void 0:
|
|
21
|
+
case "": return {
|
|
22
|
+
key: "planmagnet.invite.email.reason.unknown",
|
|
23
|
+
fallback: "No email delivery was recorded for it."
|
|
24
|
+
};
|
|
25
|
+
default: return {
|
|
26
|
+
key: "planmagnet.invite.email.reason.deliveryFailed",
|
|
27
|
+
fallback: "The email service could not send it. Try resending it."
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function n(e) {
|
|
32
|
+
switch (e) {
|
|
33
|
+
case "SENT": return {
|
|
34
|
+
key: "planmagnet.invite.email.status.sent",
|
|
35
|
+
fallback: "Email sent"
|
|
36
|
+
};
|
|
37
|
+
case "FAILED": return {
|
|
38
|
+
key: "planmagnet.invite.email.status.failed",
|
|
39
|
+
fallback: "Email not sent"
|
|
40
|
+
};
|
|
41
|
+
case "SKIPPED": return {
|
|
42
|
+
key: "planmagnet.invite.email.status.skipped",
|
|
43
|
+
fallback: "Email not sent"
|
|
44
|
+
};
|
|
45
|
+
default: return {
|
|
46
|
+
key: "planmagnet.invite.email.status.unknown",
|
|
47
|
+
fallback: "Email not recorded"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function r(e) {
|
|
52
|
+
return e === "SENT" ? "secondary" : e === "FAILED" || e === "SKIPPED" ? "destructive" : "outline";
|
|
53
|
+
}
|
|
54
|
+
function i(e) {
|
|
55
|
+
if (!e) return null;
|
|
56
|
+
let t = /^\d+$/.test(e) ? new Date(Number(e)) : new Date(e);
|
|
57
|
+
return Number.isNaN(t.getTime()) ? null : t;
|
|
58
|
+
}
|
|
59
|
+
function a(e, t) {
|
|
60
|
+
let n = [], r = 0;
|
|
61
|
+
for (let i of e) {
|
|
62
|
+
let e = t.get(i);
|
|
63
|
+
e ? n.push(e) : r++;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
names: n,
|
|
67
|
+
unknown: r
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { r as invitationEmailBadgeVariant, t as invitationEmailReason, e as invitationEmailSent, n as invitationEmailStatusLabel, a as invitationRoleNames, i as parseInvitationDate };
|
|
72
|
+
|
|
73
|
+
//# sourceMappingURL=invitationEmail.js.map
|