@burdenoff/microfe-crewfoundry 2026.801.1 → 2026.827.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/crewfoundry/components/forms/FormModal.d.ts.map +1 -1
- package/dist/crewfoundry/components/forms/FormModal.js.map +1 -1
- package/dist/crewfoundry/pages/ProjectsResourcingPagePage.d.ts.map +1 -1
- package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js +153 -69
- package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js.map +1 -1
- package/dist/crewfoundry/store/crewfoundryStore.d.ts +9 -0
- package/dist/crewfoundry/store/crewfoundryStore.d.ts.map +1 -1
- package/dist/crewfoundry/store/crewfoundryStore.js +41 -32
- package/dist/crewfoundry/store/crewfoundryStore.js.map +1 -1
- package/dist/utils/pageContext.d.ts.map +1 -1
- package/dist/utils/pageContext.js +20 -24
- package/dist/utils/pageContext.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FormModal.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/forms/FormModal.tsx"],"names":[],"mappings":"AAkBA,MAAM,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"FormModal.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/forms/FormModal.tsx"],"names":[],"mappings":"AAkBA,MAAM,MAAM,aAAa,GACvB,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEnF,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,CAAC;AAC7D,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAEnD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB,wFAAwF;IACxF,OAAO,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,CAAC;IACd,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,aAAa,CAAC,EAAE,UAAU,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAkED,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,YAAY,EACZ,KAAK,EACL,WAAW,EACX,MAAM,EACN,aAAa,EACb,WAAoB,EACpB,QAAQ,GACT,EAAE,cAAc,+BAyQhB;AAGD,MAAM,WAAW,kBAAkB,CAAC,QAAQ;IAC1C,IAAI,EAAE,OAAO,CAAC;IACd,0EAA0E;IAC1E,OAAO,EAAE,QAAQ,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE,QAAQ,KAAK,IAAI,CAAC;IACxC,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,QAAQ,GAAG,IAAI,KAAK,kBAAkB,CAAC,QAAQ,CAAC,CAmB5E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FormModal.js","names":[],"sources":["../../../../src/crewfoundry/components/forms/FormModal.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n Button,\n Input,\n Label,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n Switch,\n Textarea,\n} from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { X } from 'lucide-react';\n\n// ── Public API ──────────────────────────────────────────────────\nexport type FormFieldType =\n | 'text'\n | 'number'\n | 'date'\n | 'email'\n | 'textarea'\n | 'select'\n | 'tags'\n | 'switch';\n\nexport type FormValue = string | number | boolean | string[];\nexport type FormValues = Record<string, FormValue>;\n\nexport interface FieldOption {\n label: string;\n value: string;\n}\n\nexport interface FieldConfig {\n name: string;\n label: string;\n type: FormFieldType;\n required?: boolean;\n placeholder?: string;\n options?: FieldOption[];\n /** Column span in the 2-col grid on md+ (defaults to 1; textarea/tags default to 2). */\n colSpan?: 1 | 2;\n}\n\nexport interface FormModalProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n description?: string;\n fields: FieldConfig[];\n initialValues?: FormValues;\n submitLabel?: string;\n onSubmit: (values: FormValues) => Promise<void>;\n}\n\n// ── Internal state helpers ──────────────────────────────────────\n// Text-like inputs are held as strings for controlled inputs; switches as\n// booleans; tags as string[]. Values are coerced back to their public shapes\n// on submit.\ntype InternalValue = string | boolean | string[];\ntype InternalValues = Record<string, InternalValue>;\n\nconst buildInitialState = (fields: FieldConfig[], initialValues?: FormValues): InternalValues => {\n const state: InternalValues = {};\n for (const field of fields) {\n const raw = initialValues?.[field.name];\n if (field.type === 'switch') {\n state[field.name] = Boolean(raw);\n } else if (field.type === 'tags') {\n if (Array.isArray(raw)) {\n state[field.name] = raw.map((item) => String(item));\n } else if (typeof raw === 'string' && raw.length > 0) {\n state[field.name] = raw\n .split(',')\n .map((item) => item.trim())\n .filter(Boolean);\n } else {\n state[field.name] = [];\n }\n } else {\n state[field.name] = raw === undefined || raw === null ? '' : String(raw);\n }\n }\n return state;\n};\n\nconst isEmptyValue = (field: FieldConfig, value: InternalValue): boolean => {\n if (field.type === 'switch') {\n return false;\n }\n if (field.type === 'tags') {\n return !Array.isArray(value) || value.length === 0;\n }\n return typeof value !== 'string' || value.trim() === '';\n};\n\nconst toOutput = (fields: FieldConfig[], state: InternalValues): FormValues => {\n const output: FormValues = {};\n for (const field of fields) {\n const value = state[field.name];\n if (field.type === 'switch') {\n output[field.name] = Boolean(value);\n } else if (field.type === 'tags') {\n output[field.name] = Array.isArray(value) ? value : [];\n } else if (field.type === 'number') {\n const raw = typeof value === 'string' ? value.trim() : '';\n output[field.name] = raw === '' ? '' : Number(raw);\n } else {\n output[field.name] = typeof value === 'string' ? value : '';\n }\n }\n return output;\n};\n\n// Native <select> styled with the same semantic tokens as fe-libs Input.\nconst SELECT_CLASS =\n 'flex h-10 w-full rounded-input border border-border-default bg-bg-surface px-3 py-2 text-sm text-text-primary transition-colors duration-200 focus-visible:border-border-focus focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)] focus-visible:ring-1 focus-visible:ring-[var(--color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50';\n\n// ── Component ───────────────────────────────────────────────────\nexport function FormModal({\n open,\n onOpenChange,\n title,\n description,\n fields,\n initialValues,\n submitLabel = 'Save',\n onSubmit,\n}: FormModalProps) {\n const { showToast } = useGlobalUi();\n const [values, setValues] = useState<InternalValues>(() =>\n buildInitialState(fields, initialValues)\n );\n const [tagDrafts, setTagDrafts] = useState<Record<string, string>>({});\n const [submitting, setSubmitting] = useState(false);\n\n // Keep the latest field config / initial values reachable from the reset\n // effect without making them effect dependencies (page callers rarely\n // memoize these, and depending on them would loop or clobber edits).\n const fieldsRef = useRef(fields);\n const initialValuesRef = useRef(initialValues);\n const wasOpenRef = useRef(false);\n\n // Sync the latest field config / initial values into refs after each commit\n // (never during render). Declared before the reset effect so that on a rising\n // edge of `open` the refs are refreshed first, then read by the reset below.\n useEffect(() => {\n fieldsRef.current = fields;\n initialValuesRef.current = initialValues;\n }, [fields, initialValues]);\n\n // Reset the working copy on each rising edge of `open` so edit vs create\n // contexts never leak into each other.\n useEffect(() => {\n if (open && !wasOpenRef.current) {\n setValues(buildInitialState(fieldsRef.current, initialValuesRef.current));\n setTagDrafts({});\n setSubmitting(false);\n }\n wasOpenRef.current = open;\n }, [open]);\n\n const setFieldValue = useCallback((name: string, value: InternalValue) => {\n setValues((prev) => ({ ...prev, [name]: value }));\n }, []);\n\n const commitTag = useCallback((name: string) => {\n setTagDrafts((prevDrafts) => {\n const draft = (prevDrafts[name] ?? '').trim();\n if (draft) {\n setValues((prev) => {\n const current = Array.isArray(prev[name]) ? (prev[name] as string[]) : [];\n if (current.includes(draft)) {\n return prev;\n }\n return { ...prev, [name]: [...current, draft] };\n });\n }\n return { ...prevDrafts, [name]: '' };\n });\n }, []);\n\n const removeTag = useCallback((name: string, tag: string) => {\n setValues((prev) => {\n const current = Array.isArray(prev[name]) ? (prev[name] as string[]) : [];\n return { ...prev, [name]: current.filter((item) => item !== tag) };\n });\n }, []);\n\n const handleSubmit = useCallback(\n async (event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n if (submitting) {\n return;\n }\n\n // Fold any un-committed tag draft into its field before validating.\n const staged: InternalValues = { ...values };\n for (const field of fields) {\n if (field.type === 'tags') {\n const draft = (tagDrafts[field.name] ?? '').trim();\n if (draft) {\n const current = Array.isArray(staged[field.name])\n ? (staged[field.name] as string[])\n : [];\n if (!current.includes(draft)) {\n staged[field.name] = [...current, draft];\n }\n }\n }\n }\n\n const missing = fields.find(\n (field) => field.required && isEmptyValue(field, staged[field.name])\n );\n if (missing) {\n showToast(`${missing.label} is required`, 'warning');\n return;\n }\n\n setSubmitting(true);\n try {\n await onSubmit(toOutput(fields, staged));\n onOpenChange(false);\n } catch (error) {\n showToast(error instanceof Error ? error.message : 'Something went wrong', 'error');\n } finally {\n setSubmitting(false);\n }\n },\n [fields, onOpenChange, onSubmit, showToast, submitting, tagDrafts, values]\n );\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"max-h-[85vh] overflow-y-auto sm:max-w-[560px]\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>{title}</ResponsiveDialogTitle>\n {description ? (\n <ResponsiveDialogDescription>{description}</ResponsiveDialogDescription>\n ) : null}\n </ResponsiveDialogHeader>\n\n <form onSubmit={handleSubmit} className=\"space-y-4\">\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n {fields.map((field) => {\n const fieldId = `formmodal-${field.name}`;\n const spanTwo =\n field.colSpan === 2 ||\n (field.colSpan === undefined &&\n (field.type === 'textarea' || field.type === 'tags'));\n const value = values[field.name];\n\n return (\n <div\n key={field.name}\n className={`flex flex-col gap-1.5 ${spanTwo ? 'md:col-span-2' : ''}`}\n >\n {field.type === 'switch' ? (\n <div className=\"flex items-center justify-between gap-3 rounded-input border border-border-default bg-bg-surface px-3 py-2\">\n <Label htmlFor={fieldId}>\n {field.label}\n {field.required ? <span className=\"text-status-error-fg\"> *</span> : null}\n </Label>\n <Switch\n id={fieldId}\n checked={Boolean(value)}\n onCheckedChange={(checked) => setFieldValue(field.name, checked)}\n disabled={submitting}\n />\n </div>\n ) : (\n <>\n <Label htmlFor={fieldId}>\n {field.label}\n {field.required ? <span className=\"text-status-error-fg\"> *</span> : null}\n </Label>\n\n {field.type === 'textarea' ? (\n <Textarea\n id={fieldId}\n value={typeof value === 'string' ? value : ''}\n placeholder={field.placeholder}\n disabled={submitting}\n onChange={(event) => setFieldValue(field.name, event.target.value)}\n />\n ) : field.type === 'select' ? (\n <select\n id={fieldId}\n className={SELECT_CLASS}\n value={typeof value === 'string' ? value : ''}\n disabled={submitting}\n onChange={(event) => setFieldValue(field.name, event.target.value)}\n >\n <option value=\"\" disabled>\n {field.placeholder ?? 'Select…'}\n </option>\n {(field.options ?? []).map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n ) : field.type === 'tags' ? (\n <div className=\"flex min-h-10 flex-wrap items-center gap-1.5 rounded-input border border-border-default bg-bg-surface px-2 py-1.5\">\n {(Array.isArray(value) ? value : []).map((tag) => (\n <span\n key={tag}\n className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2 py-0.5 text-xs text-text-primary\"\n >\n {tag}\n <button\n type=\"button\"\n aria-label={`Remove ${tag}`}\n className=\"text-text-secondary hover:text-text-primary\"\n onClick={() => removeTag(field.name, tag)}\n disabled={submitting}\n >\n <X className=\"h-3 w-3\" />\n </button>\n </span>\n ))}\n <input\n id={fieldId}\n className=\"min-w-[6rem] flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder\"\n value={tagDrafts[field.name] ?? ''}\n placeholder={field.placeholder ?? 'Add and press Enter'}\n disabled={submitting}\n onChange={(event) =>\n setTagDrafts((prev) => ({\n ...prev,\n [field.name]: event.target.value,\n }))\n }\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ',') {\n event.preventDefault();\n commitTag(field.name);\n } else if (\n event.key === 'Backspace' &&\n (tagDrafts[field.name] ?? '') === ''\n ) {\n const current = Array.isArray(value) ? value : [];\n if (current.length > 0) {\n removeTag(field.name, current[current.length - 1]);\n }\n }\n }}\n onBlur={() => commitTag(field.name)}\n />\n </div>\n ) : (\n <Input\n id={fieldId}\n type={\n field.type === 'number'\n ? 'number'\n : field.type === 'date'\n ? 'date'\n : field.type === 'email'\n ? 'email'\n : 'text'\n }\n value={typeof value === 'string' ? value : ''}\n placeholder={field.placeholder}\n disabled={submitting}\n onChange={(event) => setFieldValue(field.name, event.target.value)}\n />\n )}\n </>\n )}\n </div>\n );\n })}\n </div>\n\n <ResponsiveDialogFooter>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => onOpenChange(false)}\n disabled={submitting}\n >\n Cancel\n </Button>\n <Button type=\"submit\" disabled={submitting}>\n {submitting ? 'Saving…' : submitLabel}\n </Button>\n </ResponsiveDialogFooter>\n </form>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n\n// ── Optional open/close helper ──────────────────────────────────\nexport interface UseFormModalResult<TContext> {\n open: boolean;\n /** The row/context passed to openModal (e.g. the entity being edited). */\n context: TContext | null;\n openModal: (context?: TContext) => void;\n closeModal: () => void;\n onOpenChange: (open: boolean) => void;\n}\n\n/**\n * Tiny helper for pages that toggle a single FormModal and want to remember\n * which row is being edited. `context` is null for a create flow.\n */\nexport function useFormModal<TContext = void>(): UseFormModalResult<TContext> {\n const [open, setOpen] = useState(false);\n const [context, setContext] = useState<TContext | null>(null);\n\n const openModal = useCallback((next?: TContext) => {\n setContext(next ?? null);\n setOpen(true);\n }, []);\n\n const closeModal = useCallback(() => setOpen(false), []);\n\n const onOpenChange = useCallback((nextOpen: boolean) => {\n setOpen(nextOpen);\n if (!nextOpen) {\n setContext(null);\n }\n }, []);\n\n return { open, context, openModal, closeModal, onOpenChange };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"FormModal.js","names":[],"sources":["../../../../src/crewfoundry/components/forms/FormModal.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n Button,\n Input,\n Label,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n Switch,\n Textarea,\n} from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { X } from 'lucide-react';\n\n// ── Public API ──────────────────────────────────────────────────\nexport type FormFieldType =\n 'text' | 'number' | 'date' | 'email' | 'textarea' | 'select' | 'tags' | 'switch';\n\nexport type FormValue = string | number | boolean | string[];\nexport type FormValues = Record<string, FormValue>;\n\nexport interface FieldOption {\n label: string;\n value: string;\n}\n\nexport interface FieldConfig {\n name: string;\n label: string;\n type: FormFieldType;\n required?: boolean;\n placeholder?: string;\n options?: FieldOption[];\n /** Column span in the 2-col grid on md+ (defaults to 1; textarea/tags default to 2). */\n colSpan?: 1 | 2;\n}\n\nexport interface FormModalProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n description?: string;\n fields: FieldConfig[];\n initialValues?: FormValues;\n submitLabel?: string;\n onSubmit: (values: FormValues) => Promise<void>;\n}\n\n// ── Internal state helpers ──────────────────────────────────────\n// Text-like inputs are held as strings for controlled inputs; switches as\n// booleans; tags as string[]. Values are coerced back to their public shapes\n// on submit.\ntype InternalValue = string | boolean | string[];\ntype InternalValues = Record<string, InternalValue>;\n\nconst buildInitialState = (fields: FieldConfig[], initialValues?: FormValues): InternalValues => {\n const state: InternalValues = {};\n for (const field of fields) {\n const raw = initialValues?.[field.name];\n if (field.type === 'switch') {\n state[field.name] = Boolean(raw);\n } else if (field.type === 'tags') {\n if (Array.isArray(raw)) {\n state[field.name] = raw.map((item) => String(item));\n } else if (typeof raw === 'string' && raw.length > 0) {\n state[field.name] = raw\n .split(',')\n .map((item) => item.trim())\n .filter(Boolean);\n } else {\n state[field.name] = [];\n }\n } else {\n state[field.name] = raw === undefined || raw === null ? '' : String(raw);\n }\n }\n return state;\n};\n\nconst isEmptyValue = (field: FieldConfig, value: InternalValue): boolean => {\n if (field.type === 'switch') {\n return false;\n }\n if (field.type === 'tags') {\n return !Array.isArray(value) || value.length === 0;\n }\n return typeof value !== 'string' || value.trim() === '';\n};\n\nconst toOutput = (fields: FieldConfig[], state: InternalValues): FormValues => {\n const output: FormValues = {};\n for (const field of fields) {\n const value = state[field.name];\n if (field.type === 'switch') {\n output[field.name] = Boolean(value);\n } else if (field.type === 'tags') {\n output[field.name] = Array.isArray(value) ? value : [];\n } else if (field.type === 'number') {\n const raw = typeof value === 'string' ? value.trim() : '';\n output[field.name] = raw === '' ? '' : Number(raw);\n } else {\n output[field.name] = typeof value === 'string' ? value : '';\n }\n }\n return output;\n};\n\n// Native <select> styled with the same semantic tokens as fe-libs Input.\nconst SELECT_CLASS =\n 'flex h-10 w-full rounded-input border border-border-default bg-bg-surface px-3 py-2 text-sm text-text-primary transition-colors duration-200 focus-visible:border-border-focus focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)] focus-visible:ring-1 focus-visible:ring-[var(--color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50';\n\n// ── Component ───────────────────────────────────────────────────\nexport function FormModal({\n open,\n onOpenChange,\n title,\n description,\n fields,\n initialValues,\n submitLabel = 'Save',\n onSubmit,\n}: FormModalProps) {\n const { showToast } = useGlobalUi();\n const [values, setValues] = useState<InternalValues>(() =>\n buildInitialState(fields, initialValues)\n );\n const [tagDrafts, setTagDrafts] = useState<Record<string, string>>({});\n const [submitting, setSubmitting] = useState(false);\n\n // Keep the latest field config / initial values reachable from the reset\n // effect without making them effect dependencies (page callers rarely\n // memoize these, and depending on them would loop or clobber edits).\n const fieldsRef = useRef(fields);\n const initialValuesRef = useRef(initialValues);\n const wasOpenRef = useRef(false);\n\n // Sync the latest field config / initial values into refs after each commit\n // (never during render). Declared before the reset effect so that on a rising\n // edge of `open` the refs are refreshed first, then read by the reset below.\n useEffect(() => {\n fieldsRef.current = fields;\n initialValuesRef.current = initialValues;\n }, [fields, initialValues]);\n\n // Reset the working copy on each rising edge of `open` so edit vs create\n // contexts never leak into each other.\n useEffect(() => {\n if (open && !wasOpenRef.current) {\n setValues(buildInitialState(fieldsRef.current, initialValuesRef.current));\n setTagDrafts({});\n setSubmitting(false);\n }\n wasOpenRef.current = open;\n }, [open]);\n\n const setFieldValue = useCallback((name: string, value: InternalValue) => {\n setValues((prev) => ({ ...prev, [name]: value }));\n }, []);\n\n const commitTag = useCallback((name: string) => {\n setTagDrafts((prevDrafts) => {\n const draft = (prevDrafts[name] ?? '').trim();\n if (draft) {\n setValues((prev) => {\n const current = Array.isArray(prev[name]) ? (prev[name] as string[]) : [];\n if (current.includes(draft)) {\n return prev;\n }\n return { ...prev, [name]: [...current, draft] };\n });\n }\n return { ...prevDrafts, [name]: '' };\n });\n }, []);\n\n const removeTag = useCallback((name: string, tag: string) => {\n setValues((prev) => {\n const current = Array.isArray(prev[name]) ? (prev[name] as string[]) : [];\n return { ...prev, [name]: current.filter((item) => item !== tag) };\n });\n }, []);\n\n const handleSubmit = useCallback(\n async (event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n if (submitting) {\n return;\n }\n\n // Fold any un-committed tag draft into its field before validating.\n const staged: InternalValues = { ...values };\n for (const field of fields) {\n if (field.type === 'tags') {\n const draft = (tagDrafts[field.name] ?? '').trim();\n if (draft) {\n const current = Array.isArray(staged[field.name])\n ? (staged[field.name] as string[])\n : [];\n if (!current.includes(draft)) {\n staged[field.name] = [...current, draft];\n }\n }\n }\n }\n\n const missing = fields.find(\n (field) => field.required && isEmptyValue(field, staged[field.name])\n );\n if (missing) {\n showToast(`${missing.label} is required`, 'warning');\n return;\n }\n\n setSubmitting(true);\n try {\n await onSubmit(toOutput(fields, staged));\n onOpenChange(false);\n } catch (error) {\n showToast(error instanceof Error ? error.message : 'Something went wrong', 'error');\n } finally {\n setSubmitting(false);\n }\n },\n [fields, onOpenChange, onSubmit, showToast, submitting, tagDrafts, values]\n );\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"max-h-[85vh] overflow-y-auto sm:max-w-[560px]\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>{title}</ResponsiveDialogTitle>\n {description ? (\n <ResponsiveDialogDescription>{description}</ResponsiveDialogDescription>\n ) : null}\n </ResponsiveDialogHeader>\n\n <form onSubmit={handleSubmit} className=\"space-y-4\">\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n {fields.map((field) => {\n const fieldId = `formmodal-${field.name}`;\n const spanTwo =\n field.colSpan === 2 ||\n (field.colSpan === undefined &&\n (field.type === 'textarea' || field.type === 'tags'));\n const value = values[field.name];\n\n return (\n <div\n key={field.name}\n className={`flex flex-col gap-1.5 ${spanTwo ? 'md:col-span-2' : ''}`}\n >\n {field.type === 'switch' ? (\n <div className=\"flex items-center justify-between gap-3 rounded-input border border-border-default bg-bg-surface px-3 py-2\">\n <Label htmlFor={fieldId}>\n {field.label}\n {field.required ? <span className=\"text-status-error-fg\"> *</span> : null}\n </Label>\n <Switch\n id={fieldId}\n checked={Boolean(value)}\n onCheckedChange={(checked) => setFieldValue(field.name, checked)}\n disabled={submitting}\n />\n </div>\n ) : (\n <>\n <Label htmlFor={fieldId}>\n {field.label}\n {field.required ? <span className=\"text-status-error-fg\"> *</span> : null}\n </Label>\n\n {field.type === 'textarea' ? (\n <Textarea\n id={fieldId}\n value={typeof value === 'string' ? value : ''}\n placeholder={field.placeholder}\n disabled={submitting}\n onChange={(event) => setFieldValue(field.name, event.target.value)}\n />\n ) : field.type === 'select' ? (\n <select\n id={fieldId}\n className={SELECT_CLASS}\n value={typeof value === 'string' ? value : ''}\n disabled={submitting}\n onChange={(event) => setFieldValue(field.name, event.target.value)}\n >\n <option value=\"\" disabled>\n {field.placeholder ?? 'Select…'}\n </option>\n {(field.options ?? []).map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n ) : field.type === 'tags' ? (\n <div className=\"flex min-h-10 flex-wrap items-center gap-1.5 rounded-input border border-border-default bg-bg-surface px-2 py-1.5\">\n {(Array.isArray(value) ? value : []).map((tag) => (\n <span\n key={tag}\n className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2 py-0.5 text-xs text-text-primary\"\n >\n {tag}\n <button\n type=\"button\"\n aria-label={`Remove ${tag}`}\n className=\"text-text-secondary hover:text-text-primary\"\n onClick={() => removeTag(field.name, tag)}\n disabled={submitting}\n >\n <X className=\"h-3 w-3\" />\n </button>\n </span>\n ))}\n <input\n id={fieldId}\n className=\"min-w-[6rem] flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-placeholder\"\n value={tagDrafts[field.name] ?? ''}\n placeholder={field.placeholder ?? 'Add and press Enter'}\n disabled={submitting}\n onChange={(event) =>\n setTagDrafts((prev) => ({\n ...prev,\n [field.name]: event.target.value,\n }))\n }\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ',') {\n event.preventDefault();\n commitTag(field.name);\n } else if (\n event.key === 'Backspace' &&\n (tagDrafts[field.name] ?? '') === ''\n ) {\n const current = Array.isArray(value) ? value : [];\n if (current.length > 0) {\n removeTag(field.name, current[current.length - 1]);\n }\n }\n }}\n onBlur={() => commitTag(field.name)}\n />\n </div>\n ) : (\n <Input\n id={fieldId}\n type={\n field.type === 'number'\n ? 'number'\n : field.type === 'date'\n ? 'date'\n : field.type === 'email'\n ? 'email'\n : 'text'\n }\n value={typeof value === 'string' ? value : ''}\n placeholder={field.placeholder}\n disabled={submitting}\n onChange={(event) => setFieldValue(field.name, event.target.value)}\n />\n )}\n </>\n )}\n </div>\n );\n })}\n </div>\n\n <ResponsiveDialogFooter>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => onOpenChange(false)}\n disabled={submitting}\n >\n Cancel\n </Button>\n <Button type=\"submit\" disabled={submitting}>\n {submitting ? 'Saving…' : submitLabel}\n </Button>\n </ResponsiveDialogFooter>\n </form>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n\n// ── Optional open/close helper ──────────────────────────────────\nexport interface UseFormModalResult<TContext> {\n open: boolean;\n /** The row/context passed to openModal (e.g. the entity being edited). */\n context: TContext | null;\n openModal: (context?: TContext) => void;\n closeModal: () => void;\n onOpenChange: (open: boolean) => void;\n}\n\n/**\n * Tiny helper for pages that toggle a single FormModal and want to remember\n * which row is being edited. `context` is null for a create flow.\n */\nexport function useFormModal<TContext = void>(): UseFormModalResult<TContext> {\n const [open, setOpen] = useState(false);\n const [context, setContext] = useState<TContext | null>(null);\n\n const openModal = useCallback((next?: TContext) => {\n setContext(next ?? null);\n setOpen(true);\n }, []);\n\n const closeModal = useCallback(() => setOpen(false), []);\n\n const onOpenChange = useCallback((nextOpen: boolean) => {\n setOpen(nextOpen);\n if (!nextOpen) {\n setContext(null);\n }\n }, []);\n\n return { open, context, openModal, closeModal, onOpenChange };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProjectsResourcingPagePage.d.ts","sourceRoot":"","sources":["../../../src/crewfoundry/pages/ProjectsResourcingPagePage.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ProjectsResourcingPagePage.d.ts","sourceRoot":"","sources":["../../../src/crewfoundry/pages/ProjectsResourcingPagePage.tsx"],"names":[],"mappings":"AAyEA,wBAAgB,0BAA0B,gCA8QzC"}
|
|
@@ -4,175 +4,259 @@ import { Heatmap as n } from "../components/viz/Heatmap.js";
|
|
|
4
4
|
import { AllocationBar as r } from "../components/viz/AllocationBar.js";
|
|
5
5
|
import "../components/viz/index.js";
|
|
6
6
|
import { allocationByPerson as i, benchAndOverallocated as a, skillMatch as o, utilizationByPersonWeek as s } from "../lib/analytics.js";
|
|
7
|
-
import {
|
|
8
|
-
import { AlertTriangle as
|
|
9
|
-
import { useNavigate as
|
|
10
|
-
import { Fragment as
|
|
11
|
-
import { Badge as
|
|
7
|
+
import { useEffect as c, useMemo as l } from "react";
|
|
8
|
+
import { AlertTriangle as u, ArrowLeft as d, TrendingUp as f, Users as p } from "lucide-react";
|
|
9
|
+
import { useNavigate as m } from "react-router";
|
|
10
|
+
import { Fragment as h, jsx as g, jsxs as _ } from "react/jsx-runtime";
|
|
11
|
+
import { Badge as v, Button as y, Card as b, CardContent as x, CardDescription as S, CardHeader as C, CardTitle as w, GradientText as T, IllustratedEmptyState as E, PagePurpose as D, ResponsiveTable as O, Spinner as k } from "@burdenoff/fe-libs/ui";
|
|
12
|
+
import { useGlobalUi as A } from "@burdenoff/fe-libs/shared/providers/shell";
|
|
12
13
|
//#region src/crewfoundry/pages/ProjectsResourcingPagePage.tsx
|
|
13
|
-
var
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
var j = 8, M = 12, N = 12, P = [
|
|
15
|
+
{
|
|
16
|
+
key: "skill",
|
|
17
|
+
header: "Skill",
|
|
18
|
+
priority: "primary",
|
|
19
|
+
cell: (e) => /* @__PURE__ */ g("span", {
|
|
20
|
+
className: "font-medium text-text-primary",
|
|
21
|
+
children: e.skill
|
|
22
|
+
})
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
key: "employeesWithSkill",
|
|
26
|
+
header: "People",
|
|
27
|
+
priority: "secondary",
|
|
28
|
+
cell: (e) => /* @__PURE__ */ g("span", {
|
|
29
|
+
className: "tabular-nums md:block md:text-right",
|
|
30
|
+
children: e.employeesWithSkill
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "openJobsRequiringSkill",
|
|
35
|
+
header: "Open roles",
|
|
36
|
+
priority: "secondary",
|
|
37
|
+
cell: (e) => /* @__PURE__ */ g("span", {
|
|
38
|
+
className: "tabular-nums md:block md:text-right",
|
|
39
|
+
children: e.openJobsRequiringSkill
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "shortage",
|
|
44
|
+
header: "Shortage",
|
|
45
|
+
priority: "primary",
|
|
46
|
+
cell: (e) => /* @__PURE__ */ g("span", {
|
|
47
|
+
className: "md:block md:text-right",
|
|
48
|
+
children: e.shortage > 0 ? /* @__PURE__ */ _(v, {
|
|
49
|
+
variant: "destructive",
|
|
50
|
+
className: "tabular-nums text-[10px]",
|
|
51
|
+
children: [e.shortage, " short"]
|
|
52
|
+
}) : /* @__PURE__ */ g("span", {
|
|
53
|
+
className: "text-xs text-text-muted",
|
|
54
|
+
children: "Covered"
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
];
|
|
59
|
+
function F() {
|
|
60
|
+
let F = m(), I = e((e) => e.timeEntries), L = e((e) => e.employees), R = e((e) => e.jobPostings), z = e((e) => e.candidates), B = e((e) => e.initialized), V = e((e) => e.skillsGap), H = e((e) => e.skillsGapLoaded), U = e((e) => e.fetchSkillsGap), { showToast: W } = A();
|
|
61
|
+
c(() => {
|
|
62
|
+
!B || H || U().catch((e) => {
|
|
63
|
+
W(e instanceof Error ? e.message : "Unable to load skills coverage", "error");
|
|
64
|
+
});
|
|
65
|
+
}, [
|
|
66
|
+
B,
|
|
67
|
+
H,
|
|
68
|
+
U,
|
|
69
|
+
W
|
|
19
70
|
]);
|
|
20
|
-
|
|
71
|
+
let G = l(() => s(I, L, j), [I, L]), K = l(() => a(G, L), [G, L]), q = l(() => i(I, L).slice(0, M), [I, L]), J = l(() => R.find((e) => e.status === "active"), [R]), Y = l(() => J ? o(J, L, z) : null, [
|
|
72
|
+
J,
|
|
73
|
+
L,
|
|
74
|
+
z
|
|
75
|
+
]), X = l(() => V.slice(0, N), [V]);
|
|
76
|
+
if (!B) return /* @__PURE__ */ g("div", {
|
|
21
77
|
className: "flex min-h-[60vh] items-center justify-center p-6",
|
|
22
|
-
children: /* @__PURE__ */
|
|
78
|
+
children: /* @__PURE__ */ g(k, { size: "lg" })
|
|
23
79
|
});
|
|
24
|
-
let
|
|
25
|
-
return /* @__PURE__ */
|
|
80
|
+
let Z = G.hasData && G.weekRanges.length > 0 ? `${G.weekRanges[0]} → ${G.weekRanges[G.weekRanges.length - 1]}` : null;
|
|
81
|
+
return /* @__PURE__ */ _("div", {
|
|
26
82
|
className: "space-y-6 p-6",
|
|
27
83
|
children: [
|
|
28
|
-
/* @__PURE__ */
|
|
84
|
+
/* @__PURE__ */ _(y, {
|
|
29
85
|
variant: "ghost",
|
|
30
86
|
size: "sm",
|
|
31
|
-
onClick: () =>
|
|
32
|
-
children: [/* @__PURE__ */
|
|
87
|
+
onClick: () => F("../projects/projects-page"),
|
|
88
|
+
children: [/* @__PURE__ */ g(d, { className: "mr-1 h-4 w-4" }), "Back to projects"]
|
|
33
89
|
}),
|
|
34
|
-
/* @__PURE__ */
|
|
90
|
+
/* @__PURE__ */ _("div", {
|
|
35
91
|
className: "flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between",
|
|
36
|
-
children: [/* @__PURE__ */
|
|
37
|
-
/* @__PURE__ */
|
|
92
|
+
children: [/* @__PURE__ */ _("div", { children: [
|
|
93
|
+
/* @__PURE__ */ g("p", {
|
|
38
94
|
className: "text-sm text-text-secondary",
|
|
39
95
|
children: "Skills-based staffing & utilization"
|
|
40
96
|
}),
|
|
41
|
-
/* @__PURE__ */
|
|
97
|
+
/* @__PURE__ */ g("h1", {
|
|
42
98
|
className: "text-2xl font-semibold",
|
|
43
|
-
children: /* @__PURE__ */
|
|
99
|
+
children: /* @__PURE__ */ g(T, { children: "Resourcing & Utilization" })
|
|
44
100
|
}),
|
|
45
|
-
/* @__PURE__ */
|
|
101
|
+
/* @__PURE__ */ g(D, {
|
|
46
102
|
className: "mt-1",
|
|
47
103
|
children: "See who is over- or under-utilized week by week, how people are allocated across projects, and which candidates best match an open role — all from real logged time."
|
|
48
104
|
})
|
|
49
|
-
] }), /* @__PURE__ */
|
|
105
|
+
] }), /* @__PURE__ */ _("div", {
|
|
50
106
|
className: "grid grid-cols-1 gap-3 sm:grid-cols-3 lg:w-auto",
|
|
51
107
|
children: [
|
|
52
|
-
/* @__PURE__ */
|
|
108
|
+
/* @__PURE__ */ g(t, {
|
|
53
109
|
label: "Avg utilization",
|
|
54
|
-
value: `${
|
|
55
|
-
icon: /* @__PURE__ */
|
|
110
|
+
value: `${K.avgUtilization}%`,
|
|
111
|
+
icon: /* @__PURE__ */ g(f, { className: "h-4 w-4" })
|
|
56
112
|
}),
|
|
57
|
-
/* @__PURE__ */
|
|
113
|
+
/* @__PURE__ */ g(t, {
|
|
58
114
|
label: "Bench",
|
|
59
|
-
value:
|
|
60
|
-
icon: /* @__PURE__ */
|
|
115
|
+
value: K.bench,
|
|
116
|
+
icon: /* @__PURE__ */ g(p, { className: "h-4 w-4" })
|
|
61
117
|
}),
|
|
62
|
-
/* @__PURE__ */
|
|
118
|
+
/* @__PURE__ */ g(t, {
|
|
63
119
|
label: "Over-allocated",
|
|
64
|
-
value:
|
|
65
|
-
icon: /* @__PURE__ */
|
|
120
|
+
value: K.overAllocated,
|
|
121
|
+
icon: /* @__PURE__ */ g(u, { className: "h-4 w-4" })
|
|
66
122
|
})
|
|
67
123
|
]
|
|
68
124
|
})]
|
|
69
125
|
}),
|
|
70
|
-
/* @__PURE__ */
|
|
126
|
+
/* @__PURE__ */ _("div", {
|
|
71
127
|
className: "grid gap-4 lg:grid-cols-3",
|
|
72
|
-
children: [/* @__PURE__ */
|
|
128
|
+
children: [/* @__PURE__ */ _(b, {
|
|
73
129
|
className: "lg:col-span-2",
|
|
74
|
-
children: [/* @__PURE__ */
|
|
130
|
+
children: [/* @__PURE__ */ _(C, { children: [/* @__PURE__ */ g(w, {
|
|
75
131
|
className: "text-base",
|
|
76
132
|
children: "Utilization heatmap"
|
|
77
|
-
}), /* @__PURE__ */
|
|
133
|
+
}), /* @__PURE__ */ g(S, { children: Z ? `Weekly utilization (Σ logged hours ÷ 40h capacity) · ${Z}` : "Weekly utilization from logged time" })] }), /* @__PURE__ */ g(x, { children: G.hasData ? /* @__PURE__ */ _("div", {
|
|
78
134
|
className: "space-y-3",
|
|
79
|
-
children: [/* @__PURE__ */
|
|
80
|
-
rows:
|
|
81
|
-
colLabels:
|
|
135
|
+
children: [/* @__PURE__ */ g(n, {
|
|
136
|
+
rows: G.rows,
|
|
137
|
+
colLabels: G.weekLabels,
|
|
82
138
|
showValues: !0,
|
|
83
139
|
valueFormatter: (e) => `${e}%`,
|
|
84
140
|
title: "Per-person weekly utilization"
|
|
85
|
-
}), /* @__PURE__ */
|
|
141
|
+
}), /* @__PURE__ */ g("ul", {
|
|
86
142
|
className: "flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted",
|
|
87
|
-
children:
|
|
88
|
-
/* @__PURE__ */
|
|
143
|
+
children: G.weekLabels.map((e, t) => /* @__PURE__ */ _("li", { children: [
|
|
144
|
+
/* @__PURE__ */ g("span", {
|
|
89
145
|
className: "font-medium text-text-secondary",
|
|
90
146
|
children: e
|
|
91
147
|
}),
|
|
92
148
|
" ",
|
|
93
|
-
|
|
149
|
+
G.weekRanges[t]
|
|
94
150
|
] }, e))
|
|
95
151
|
})]
|
|
96
|
-
}) : /* @__PURE__ */
|
|
152
|
+
}) : /* @__PURE__ */ g(E, {
|
|
97
153
|
illustration: "empty-data",
|
|
98
154
|
title: "No time entries yet",
|
|
99
155
|
description: "Once your team logs time, per-person weekly utilization will appear here. Nothing is shown for weeks without real entries."
|
|
100
156
|
}) })]
|
|
101
|
-
}), /* @__PURE__ */
|
|
157
|
+
}), /* @__PURE__ */ _(b, { children: [/* @__PURE__ */ _(C, { children: [/* @__PURE__ */ g(w, {
|
|
102
158
|
className: "text-base",
|
|
103
159
|
children: "Skills match for open role"
|
|
104
|
-
}),
|
|
105
|
-
|
|
160
|
+
}), Y ? /* @__PURE__ */ _(S, { children: [
|
|
161
|
+
Y.roleTitle,
|
|
106
162
|
" ·",
|
|
107
163
|
" ",
|
|
108
|
-
|
|
109
|
-
] }) : null] }), /* @__PURE__ */
|
|
164
|
+
Y.source === "employee" ? "internal team" : "candidate pool"
|
|
165
|
+
] }) : null] }), /* @__PURE__ */ g(x, {
|
|
110
166
|
className: "space-y-4",
|
|
111
|
-
children:
|
|
167
|
+
children: J ? Y && Y.requiredSkills.length > 0 ? /* @__PURE__ */ _(h, { children: [/* @__PURE__ */ g("div", {
|
|
112
168
|
className: "flex flex-wrap gap-1.5",
|
|
113
|
-
children:
|
|
169
|
+
children: Y.requiredSkills.map((e) => /* @__PURE__ */ g(v, {
|
|
114
170
|
variant: "outline",
|
|
115
171
|
className: "text-[11px]",
|
|
116
172
|
children: e
|
|
117
173
|
}, e))
|
|
118
|
-
}),
|
|
174
|
+
}), Y.matches.length > 0 ? /* @__PURE__ */ g("ol", {
|
|
119
175
|
className: "space-y-3",
|
|
120
|
-
children:
|
|
176
|
+
children: Y.matches.map((e, t) => /* @__PURE__ */ _("li", {
|
|
121
177
|
className: "rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3",
|
|
122
178
|
children: [
|
|
123
|
-
/* @__PURE__ */
|
|
179
|
+
/* @__PURE__ */ _("div", {
|
|
124
180
|
className: "flex items-baseline justify-between gap-2",
|
|
125
|
-
children: [/* @__PURE__ */
|
|
181
|
+
children: [/* @__PURE__ */ _("span", {
|
|
126
182
|
className: "text-sm font-medium text-text-primary",
|
|
127
183
|
children: [
|
|
128
184
|
t + 1,
|
|
129
185
|
". ",
|
|
130
186
|
e.name
|
|
131
187
|
]
|
|
132
|
-
}), /* @__PURE__ */
|
|
188
|
+
}), /* @__PURE__ */ _("span", {
|
|
133
189
|
className: "text-sm font-semibold tabular-nums text-status-success-text",
|
|
134
190
|
children: [e.matchPercent, "% match"]
|
|
135
191
|
})]
|
|
136
192
|
}),
|
|
137
|
-
/* @__PURE__ */
|
|
193
|
+
/* @__PURE__ */ g("div", {
|
|
138
194
|
className: "mt-2 flex flex-wrap gap-1",
|
|
139
|
-
children: e.matchedSkills.map((e) => /* @__PURE__ */
|
|
195
|
+
children: e.matchedSkills.map((e) => /* @__PURE__ */ g(v, {
|
|
140
196
|
variant: "secondary",
|
|
141
197
|
className: "text-[10px]",
|
|
142
198
|
children: e
|
|
143
199
|
}, e))
|
|
144
200
|
}),
|
|
145
|
-
e.detail ? /* @__PURE__ */
|
|
201
|
+
e.detail ? /* @__PURE__ */ g("p", {
|
|
146
202
|
className: "mt-2 text-xs text-text-muted",
|
|
147
203
|
children: e.detail
|
|
148
204
|
}) : null
|
|
149
205
|
]
|
|
150
206
|
}, e.id))
|
|
151
|
-
}) : /* @__PURE__ */
|
|
207
|
+
}) : /* @__PURE__ */ g(E, {
|
|
152
208
|
illustration: "empty-people",
|
|
153
209
|
title: "No people match yet",
|
|
154
210
|
description: "No recorded skills overlap this role’s required skills."
|
|
155
|
-
})] }) : /* @__PURE__ */
|
|
211
|
+
})] }) : /* @__PURE__ */ g(E, {
|
|
156
212
|
illustration: "empty-data",
|
|
157
213
|
title: "No skill tags on this role",
|
|
158
214
|
description: "Add recognised skill keywords to this role’s requirements to rank candidate matches."
|
|
159
|
-
}) : /* @__PURE__ */
|
|
215
|
+
}) : /* @__PURE__ */ g(E, {
|
|
160
216
|
illustration: "empty-search",
|
|
161
217
|
title: "No active roles",
|
|
162
218
|
description: "Publish a job posting to match candidates against its required skills."
|
|
163
219
|
})
|
|
164
220
|
})] })]
|
|
165
221
|
}),
|
|
166
|
-
/* @__PURE__ */
|
|
222
|
+
/* @__PURE__ */ _(b, { children: [/* @__PURE__ */ _(C, { children: [/* @__PURE__ */ g(w, {
|
|
223
|
+
className: "text-base",
|
|
224
|
+
children: "Skills coverage"
|
|
225
|
+
}), /* @__PURE__ */ g(S, { children: "Every skill tagged on an active employee or listed on an open role — how many people have it against how many open roles ask for it." })] }), /* @__PURE__ */ g(x, { children: H ? V.length > 0 ? /* @__PURE__ */ _("div", {
|
|
226
|
+
className: "space-y-3",
|
|
227
|
+
children: [/* @__PURE__ */ g(O, {
|
|
228
|
+
columns: P,
|
|
229
|
+
data: X,
|
|
230
|
+
rowKey: (e) => e.skill
|
|
231
|
+
}), V.length > X.length ? /* @__PURE__ */ _("p", {
|
|
232
|
+
className: "text-xs text-text-muted",
|
|
233
|
+
children: [
|
|
234
|
+
"Showing the ",
|
|
235
|
+
X.length,
|
|
236
|
+
" skills with the largest shortage of",
|
|
237
|
+
" ",
|
|
238
|
+
V.length,
|
|
239
|
+
" tracked."
|
|
240
|
+
]
|
|
241
|
+
}) : null]
|
|
242
|
+
}) : /* @__PURE__ */ g(E, {
|
|
243
|
+
illustration: "empty-data",
|
|
244
|
+
title: "No skills recorded yet",
|
|
245
|
+
description: "Tag skills on employee profiles and list requirements on job postings to see where supply and demand diverge."
|
|
246
|
+
}) : /* @__PURE__ */ g("div", {
|
|
247
|
+
className: "flex min-h-[8rem] items-center justify-center",
|
|
248
|
+
children: /* @__PURE__ */ g(k, { size: "md" })
|
|
249
|
+
}) })] }),
|
|
250
|
+
/* @__PURE__ */ _(b, { children: [/* @__PURE__ */ _(C, { children: [/* @__PURE__ */ g(w, {
|
|
167
251
|
className: "text-base",
|
|
168
252
|
children: "People allocation"
|
|
169
|
-
}), /* @__PURE__ */
|
|
253
|
+
}), /* @__PURE__ */ g(S, { children: q.length > 0 ? `Project split for the latest week with logged time (${q[0].weekLabel})` : "Per-person project allocation from logged time" })] }), /* @__PURE__ */ g(x, {
|
|
170
254
|
className: "space-y-5",
|
|
171
|
-
children:
|
|
255
|
+
children: q.length > 0 ? q.map((e) => /* @__PURE__ */ g(r, {
|
|
172
256
|
segments: e.segments,
|
|
173
257
|
label: e.name,
|
|
174
258
|
rightLabel: `${e.utilization}% utilized`
|
|
175
|
-
}, e.employeeId)) : /* @__PURE__ */
|
|
259
|
+
}, e.employeeId)) : /* @__PURE__ */ g(E, {
|
|
176
260
|
illustration: "empty-data",
|
|
177
261
|
title: "No allocation to show",
|
|
178
262
|
description: "No time entries have been logged yet, so there is nothing to allocate across projects."
|
|
@@ -182,6 +266,6 @@ function A() {
|
|
|
182
266
|
});
|
|
183
267
|
}
|
|
184
268
|
//#endregion
|
|
185
|
-
export {
|
|
269
|
+
export { F as ProjectsResourcingPagePage };
|
|
186
270
|
|
|
187
271
|
//# sourceMappingURL=ProjectsResourcingPagePage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProjectsResourcingPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/ProjectsResourcingPagePage.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport { AlertTriangle, ArrowLeft, TrendingUp, Users } from 'lucide-react';\nimport { useNavigate } from 'react-router';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { AllocationBar, Heatmap, StatCard } from '../components/viz';\nimport {\n allocationByPerson,\n benchAndOverallocated,\n skillMatch,\n utilizationByPersonWeek,\n} from '../lib/analytics';\n\nconst UTILIZATION_WEEKS = 8;\nconst MAX_ALLOCATION_ROWS = 12;\n\nexport function ProjectsResourcingPagePage() {\n const navigate = useNavigate();\n const timeEntries = useCrewFoundryStore((state) => state.timeEntries);\n const employees = useCrewFoundryStore((state) => state.employees);\n const jobPostings = useCrewFoundryStore((state) => state.jobPostings);\n const candidates = useCrewFoundryStore((state) => state.candidates);\n const initialized = useCrewFoundryStore((state) => state.initialized);\n\n const matrix = useMemo(\n () => utilizationByPersonWeek(timeEntries, employees, UTILIZATION_WEEKS),\n [timeEntries, employees]\n );\n const kpis = useMemo(() => benchAndOverallocated(matrix, employees), [matrix, employees]);\n const allocation = useMemo(\n () => allocationByPerson(timeEntries, employees).slice(0, MAX_ALLOCATION_ROWS),\n [timeEntries, employees]\n );\n const openJob = useMemo(() => jobPostings.find((job) => job.status === 'active'), [jobPostings]);\n // Prefer real employee skills; skillMatch falls back to the candidate pool\n // when no employee has skills recorded.\n const skills = useMemo(\n () => (openJob ? skillMatch(openJob, employees, candidates) : null),\n [openJob, employees, candidates]\n );\n\n if (!initialized) {\n return (\n <div className=\"flex min-h-[60vh] items-center justify-center p-6\">\n <Spinner size=\"lg\" />\n </div>\n );\n }\n\n const weekRangeCaption =\n matrix.hasData && matrix.weekRanges.length > 0\n ? `${matrix.weekRanges[0]} → ${matrix.weekRanges[matrix.weekRanges.length - 1]}`\n : null;\n\n return (\n <div className=\"space-y-6 p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigate('../projects/projects-page')}>\n <ArrowLeft className=\"mr-1 h-4 w-4\" />\n Back to projects\n </Button>\n\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <div>\n <p className=\"text-sm text-text-secondary\">Skills-based staffing & utilization</p>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Resourcing & Utilization</GradientText>\n </h1>\n <PagePurpose className=\"mt-1\">\n See who is over- or under-utilized week by week, how people are allocated across\n projects, and which candidates best match an open role — all from real logged time.\n </PagePurpose>\n </div>\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-3 lg:w-auto\">\n <StatCard\n label=\"Avg utilization\"\n value={`${kpis.avgUtilization}%`}\n icon={<TrendingUp className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Bench\" value={kpis.bench} icon={<Users className=\"h-4 w-4\" />} />\n <StatCard\n label=\"Over-allocated\"\n value={kpis.overAllocated}\n icon={<AlertTriangle className=\"h-4 w-4\" />}\n />\n </div>\n </div>\n\n <div className=\"grid gap-4 lg:grid-cols-3\">\n {/* Utilization heatmap */}\n <Card className=\"lg:col-span-2\">\n <CardHeader>\n <CardTitle className=\"text-base\">Utilization heatmap</CardTitle>\n <CardDescription>\n {weekRangeCaption\n ? `Weekly utilization (Σ logged hours ÷ 40h capacity) · ${weekRangeCaption}`\n : 'Weekly utilization from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent>\n {matrix.hasData ? (\n <div className=\"space-y-3\">\n <Heatmap\n rows={matrix.rows}\n colLabels={matrix.weekLabels}\n showValues\n valueFormatter={(value) => `${value}%`}\n title=\"Per-person weekly utilization\"\n />\n <ul className=\"flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted\">\n {matrix.weekLabels.map((label, index) => (\n <li key={label}>\n <span className=\"font-medium text-text-secondary\">{label}</span>{' '}\n {matrix.weekRanges[index]}\n </li>\n ))}\n </ul>\n </div>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No time entries yet\"\n description=\"Once your team logs time, per-person weekly utilization will appear here. Nothing is shown for weeks without real entries.\"\n />\n )}\n </CardContent>\n </Card>\n\n {/* Skills match for open role */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Skills match for open role</CardTitle>\n {skills ? (\n <CardDescription>\n {skills.roleTitle} ·{' '}\n {skills.source === 'employee' ? 'internal team' : 'candidate pool'}\n </CardDescription>\n ) : null}\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {!openJob ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No active roles\"\n description=\"Publish a job posting to match candidates against its required skills.\"\n />\n ) : skills && skills.requiredSkills.length > 0 ? (\n <>\n <div className=\"flex flex-wrap gap-1.5\">\n {skills.requiredSkills.map((skill) => (\n <Badge key={skill} variant=\"outline\" className=\"text-[11px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {skills.matches.length > 0 ? (\n <ol className=\"space-y-3\">\n {skills.matches.map((match, index) => (\n <li\n key={match.id}\n className=\"rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3\"\n >\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-sm font-medium text-text-primary\">\n {index + 1}. {match.name}\n </span>\n <span className=\"text-sm font-semibold tabular-nums text-status-success-text\">\n {match.matchPercent}% match\n </span>\n </div>\n <div className=\"mt-2 flex flex-wrap gap-1\">\n {match.matchedSkills.map((skill) => (\n <Badge key={skill} variant=\"secondary\" className=\"text-[10px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {match.detail ? (\n <p className=\"mt-2 text-xs text-text-muted\">{match.detail}</p>\n ) : null}\n </li>\n ))}\n </ol>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-people\"\n title=\"No people match yet\"\n description=\"No recorded skills overlap this role’s required skills.\"\n />\n )}\n </>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skill tags on this role\"\n description=\"Add recognised skill keywords to this role’s requirements to rank candidate matches.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n {/* People allocation this week */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">People allocation</CardTitle>\n <CardDescription>\n {allocation.length > 0\n ? `Project split for the latest week with logged time (${allocation[0].weekLabel})`\n : 'Per-person project allocation from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n {allocation.length > 0 ? (\n allocation.map((row) => (\n <AllocationBar\n key={row.employeeId}\n segments={row.segments}\n label={row.name}\n rightLabel={`${row.utilization}% utilized`}\n />\n ))\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No allocation to show\"\n description=\"No time entries have been logged yet, so there is nothing to allocate across projects.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ProjectsResourcingPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/ProjectsResourcingPagePage.tsx"],"sourcesContent":["import { useEffect, useMemo } from 'react';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n ResponsiveTable,\n type ResponsiveTableColumn,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { AlertTriangle, ArrowLeft, TrendingUp, Users } from 'lucide-react';\nimport { useNavigate } from 'react-router';\nimport { useCrewFoundryStore, type CrewSkillsGapRow } from '../store/crewfoundryStore';\nimport { AllocationBar, Heatmap, StatCard } from '../components/viz';\nimport {\n allocationByPerson,\n benchAndOverallocated,\n skillMatch,\n utilizationByPersonWeek,\n} from '../lib/analytics';\n\nconst UTILIZATION_WEEKS = 8;\nconst MAX_ALLOCATION_ROWS = 12;\nconst MAX_SKILLS_GAP_ROWS = 12;\n\nconst skillsGapColumns: ResponsiveTableColumn<CrewSkillsGapRow>[] = [\n {\n key: 'skill',\n header: 'Skill',\n priority: 'primary',\n cell: (row) => <span className=\"font-medium text-text-primary\">{row.skill}</span>,\n },\n {\n key: 'employeesWithSkill',\n header: 'People',\n priority: 'secondary',\n cell: (row) => (\n <span className=\"tabular-nums md:block md:text-right\">{row.employeesWithSkill}</span>\n ),\n },\n {\n key: 'openJobsRequiringSkill',\n header: 'Open roles',\n priority: 'secondary',\n cell: (row) => (\n <span className=\"tabular-nums md:block md:text-right\">{row.openJobsRequiringSkill}</span>\n ),\n },\n {\n key: 'shortage',\n header: 'Shortage',\n priority: 'primary',\n cell: (row) => (\n <span className=\"md:block md:text-right\">\n {row.shortage > 0 ? (\n <Badge variant=\"destructive\" className=\"tabular-nums text-[10px]\">\n {row.shortage} short\n </Badge>\n ) : (\n <span className=\"text-xs text-text-muted\">Covered</span>\n )}\n </span>\n ),\n },\n];\n\nexport function ProjectsResourcingPagePage() {\n const navigate = useNavigate();\n const timeEntries = useCrewFoundryStore((state) => state.timeEntries);\n const employees = useCrewFoundryStore((state) => state.employees);\n const jobPostings = useCrewFoundryStore((state) => state.jobPostings);\n const candidates = useCrewFoundryStore((state) => state.candidates);\n const initialized = useCrewFoundryStore((state) => state.initialized);\n const skillsGap = useCrewFoundryStore((state) => state.skillsGap);\n const skillsGapLoaded = useCrewFoundryStore((state) => state.skillsGapLoaded);\n const fetchSkillsGap = useCrewFoundryStore((state) => state.fetchSkillsGap);\n const { showToast } = useGlobalUi();\n\n // Skills coverage is not part of the bootstrap payload — fetch it once, on\n // the only page that renders it.\n useEffect(() => {\n if (!initialized || skillsGapLoaded) return;\n fetchSkillsGap().catch((error: unknown) => {\n showToast(error instanceof Error ? error.message : 'Unable to load skills coverage', 'error');\n });\n }, [initialized, skillsGapLoaded, fetchSkillsGap, showToast]);\n\n const matrix = useMemo(\n () => utilizationByPersonWeek(timeEntries, employees, UTILIZATION_WEEKS),\n [timeEntries, employees]\n );\n const kpis = useMemo(() => benchAndOverallocated(matrix, employees), [matrix, employees]);\n const allocation = useMemo(\n () => allocationByPerson(timeEntries, employees).slice(0, MAX_ALLOCATION_ROWS),\n [timeEntries, employees]\n );\n const openJob = useMemo(() => jobPostings.find((job) => job.status === 'active'), [jobPostings]);\n // Prefer real employee skills; skillMatch falls back to the candidate pool\n // when no employee has skills recorded.\n const skills = useMemo(\n () => (openJob ? skillMatch(openJob, employees, candidates) : null),\n [openJob, employees, candidates]\n );\n // The resolver already sorts by shortage desc, then demand desc — take the\n // head rather than re-sorting so the ordering stays the backend's.\n const visibleSkillsGap = useMemo(() => skillsGap.slice(0, MAX_SKILLS_GAP_ROWS), [skillsGap]);\n\n if (!initialized) {\n return (\n <div className=\"flex min-h-[60vh] items-center justify-center p-6\">\n <Spinner size=\"lg\" />\n </div>\n );\n }\n\n const weekRangeCaption =\n matrix.hasData && matrix.weekRanges.length > 0\n ? `${matrix.weekRanges[0]} → ${matrix.weekRanges[matrix.weekRanges.length - 1]}`\n : null;\n\n return (\n <div className=\"space-y-6 p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigate('../projects/projects-page')}>\n <ArrowLeft className=\"mr-1 h-4 w-4\" />\n Back to projects\n </Button>\n\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <div>\n <p className=\"text-sm text-text-secondary\">Skills-based staffing & utilization</p>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Resourcing & Utilization</GradientText>\n </h1>\n <PagePurpose className=\"mt-1\">\n See who is over- or under-utilized week by week, how people are allocated across\n projects, and which candidates best match an open role — all from real logged time.\n </PagePurpose>\n </div>\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-3 lg:w-auto\">\n <StatCard\n label=\"Avg utilization\"\n value={`${kpis.avgUtilization}%`}\n icon={<TrendingUp className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Bench\" value={kpis.bench} icon={<Users className=\"h-4 w-4\" />} />\n <StatCard\n label=\"Over-allocated\"\n value={kpis.overAllocated}\n icon={<AlertTriangle className=\"h-4 w-4\" />}\n />\n </div>\n </div>\n\n <div className=\"grid gap-4 lg:grid-cols-3\">\n {/* Utilization heatmap */}\n <Card className=\"lg:col-span-2\">\n <CardHeader>\n <CardTitle className=\"text-base\">Utilization heatmap</CardTitle>\n <CardDescription>\n {weekRangeCaption\n ? `Weekly utilization (Σ logged hours ÷ 40h capacity) · ${weekRangeCaption}`\n : 'Weekly utilization from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent>\n {matrix.hasData ? (\n <div className=\"space-y-3\">\n <Heatmap\n rows={matrix.rows}\n colLabels={matrix.weekLabels}\n showValues\n valueFormatter={(value) => `${value}%`}\n title=\"Per-person weekly utilization\"\n />\n <ul className=\"flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted\">\n {matrix.weekLabels.map((label, index) => (\n <li key={label}>\n <span className=\"font-medium text-text-secondary\">{label}</span>{' '}\n {matrix.weekRanges[index]}\n </li>\n ))}\n </ul>\n </div>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No time entries yet\"\n description=\"Once your team logs time, per-person weekly utilization will appear here. Nothing is shown for weeks without real entries.\"\n />\n )}\n </CardContent>\n </Card>\n\n {/* Skills match for open role */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Skills match for open role</CardTitle>\n {skills ? (\n <CardDescription>\n {skills.roleTitle} ·{' '}\n {skills.source === 'employee' ? 'internal team' : 'candidate pool'}\n </CardDescription>\n ) : null}\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {!openJob ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No active roles\"\n description=\"Publish a job posting to match candidates against its required skills.\"\n />\n ) : skills && skills.requiredSkills.length > 0 ? (\n <>\n <div className=\"flex flex-wrap gap-1.5\">\n {skills.requiredSkills.map((skill) => (\n <Badge key={skill} variant=\"outline\" className=\"text-[11px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {skills.matches.length > 0 ? (\n <ol className=\"space-y-3\">\n {skills.matches.map((match, index) => (\n <li\n key={match.id}\n className=\"rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3\"\n >\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-sm font-medium text-text-primary\">\n {index + 1}. {match.name}\n </span>\n <span className=\"text-sm font-semibold tabular-nums text-status-success-text\">\n {match.matchPercent}% match\n </span>\n </div>\n <div className=\"mt-2 flex flex-wrap gap-1\">\n {match.matchedSkills.map((skill) => (\n <Badge key={skill} variant=\"secondary\" className=\"text-[10px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {match.detail ? (\n <p className=\"mt-2 text-xs text-text-muted\">{match.detail}</p>\n ) : null}\n </li>\n ))}\n </ol>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-people\"\n title=\"No people match yet\"\n description=\"No recorded skills overlap this role’s required skills.\"\n />\n )}\n </>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skill tags on this role\"\n description=\"Add recognised skill keywords to this role’s requirements to rank candidate matches.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n {/* Skills coverage across the workspace */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Skills coverage</CardTitle>\n <CardDescription>\n Every skill tagged on an active employee or listed on an open role — how many people\n have it against how many open roles ask for it.\n </CardDescription>\n </CardHeader>\n <CardContent>\n {!skillsGapLoaded ? (\n <div className=\"flex min-h-[8rem] items-center justify-center\">\n <Spinner size=\"md\" />\n </div>\n ) : skillsGap.length > 0 ? (\n <div className=\"space-y-3\">\n <ResponsiveTable\n columns={skillsGapColumns}\n data={visibleSkillsGap}\n rowKey={(row) => row.skill}\n />\n {skillsGap.length > visibleSkillsGap.length ? (\n <p className=\"text-xs text-text-muted\">\n Showing the {visibleSkillsGap.length} skills with the largest shortage of{' '}\n {skillsGap.length} tracked.\n </p>\n ) : null}\n </div>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skills recorded yet\"\n description=\"Tag skills on employee profiles and list requirements on job postings to see where supply and demand diverge.\"\n />\n )}\n </CardContent>\n </Card>\n\n {/* People allocation this week */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">People allocation</CardTitle>\n <CardDescription>\n {allocation.length > 0\n ? `Project split for the latest week with logged time (${allocation[0].weekLabel})`\n : 'Per-person project allocation from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n {allocation.length > 0 ? (\n allocation.map((row) => (\n <AllocationBar\n key={row.employeeId}\n segments={row.segments}\n label={row.name}\n rightLabel={`${row.utilization}% utilized`}\n />\n ))\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No allocation to show\"\n description=\"No time entries have been logged yet, so there is nothing to allocate across projects.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -299,6 +299,12 @@ export interface CrewCandidateMatch {
|
|
|
299
299
|
missingSkills: string[];
|
|
300
300
|
rationale: string;
|
|
301
301
|
}
|
|
302
|
+
export interface CrewSkillsGapRow {
|
|
303
|
+
skill: string;
|
|
304
|
+
employeesWithSkill: number;
|
|
305
|
+
openJobsRequiringSkill: number;
|
|
306
|
+
shortage: number;
|
|
307
|
+
}
|
|
302
308
|
export interface CrewPayrollRun {
|
|
303
309
|
id: string;
|
|
304
310
|
country: 'US' | 'IN' | 'GB';
|
|
@@ -403,6 +409,8 @@ interface CrewFoundryState {
|
|
|
403
409
|
payrollRunsLoading: boolean;
|
|
404
410
|
payslipsByRun: Record<string, CrewPayslip[]>;
|
|
405
411
|
payslipsLoading: boolean;
|
|
412
|
+
skillsGap: CrewSkillsGapRow[];
|
|
413
|
+
skillsGapLoaded: boolean;
|
|
406
414
|
initialized: boolean;
|
|
407
415
|
loading: boolean;
|
|
408
416
|
error: string | null;
|
|
@@ -507,6 +515,7 @@ interface CrewFoundryState {
|
|
|
507
515
|
updateOnboardingTask: (id: string, updates: Partial<CrewOnboardingTask>) => Promise<void>;
|
|
508
516
|
removeOnboardingTask: (id: string) => Promise<void>;
|
|
509
517
|
toggleOnboardingTask: (id: string, completed: boolean) => Promise<void>;
|
|
518
|
+
fetchSkillsGap: () => Promise<void>;
|
|
510
519
|
}
|
|
511
520
|
export declare const useCrewFoundryStore: import('zustand').UseBoundStore<import('zustand').StoreApi<CrewFoundryState>>;
|
|
512
521
|
export {};
|