diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5615e88a7..5b1dd8d47 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,14 +33,15 @@ jobs: BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Sync environment from server + - name: Sync environment from Env Manager App run: | chmod +x scripts/deploy/*.sh - ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}" - name: Set compose project name run: | diff --git a/Dockerfile b/Dockerfile index dbab371f4..f891156c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,19 @@ FROM node:24-alpine AS deps WORKDIR /app -COPY package.json package-lock.json* ./ +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY local-packages/ ./local-packages/ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ - npm install --legacy-peer-deps + pnpm install --frozen-lockfile FROM deps AS base COPY . . FROM base AS portal-build -RUN npm run build:portal +RUN pnpm run build:portal FROM base AS backoffice-build -RUN npm run build:backoffice +RUN pnpm run build:backoffice FROM nginx:1.29-alpine AS portal COPY --from=portal-build /app/dist/apps/portal /usr/share/nginx/html diff --git a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx index 8e8684350..b0f91cab9 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/BlockPropertiesPanel.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Button, ColorInput, Group, NumberInput, @@ -11,7 +12,7 @@ import { TextInput, Tooltip, } from '@mantine/core'; -import { IconTrash } from '@tabler/icons-react'; +import { IconBold, IconItalic, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import type { TemplateFieldPlacement, TemplateVariable } from '@ema-platform/api'; @@ -43,7 +44,17 @@ export function BlockPropertiesPanel({ ); } - const isLiteral = block.variable === null; + const current = block; + const isLiteral = current.variable === null; + const isImage = current.type === 'image'; + const variableByKey = new Map(variables.map((v) => [v.key, v])); + + const selectVariable = (key: string | null) => { + if (!key) return; + const kind: 'text' | 'image' = + variableByKey.get(key)?.kind === 'image' ? 'image' : 'text'; + onChange({ ...current, variable: key, type: kind }); + }; return ( @@ -70,11 +81,9 @@ export function BlockPropertiesPanel({ value={isLiteral ? 'text' : 'variable'} disabled={disabled} onChange={(value) => - onChange( - value === 'text' - ? { ...block, variable: null, text: block.text ?? '' } - : { ...block, variable: variables[0]?.key ?? 'companyName' }, - ) + value === 'text' + ? onChange({ ...block, variable: null, type: 'text', text: block.text ?? '' }) + : selectVariable(variables[0]?.key ?? 'companyName') } data={[ { value: 'variable', label: t('designer.blockVariable', 'Variable') }, @@ -94,24 +103,26 @@ export function BlockPropertiesPanel({ label={t('designer.blockVariableLabel', 'Variable')} data={variables.map((variable) => ({ value: variable.key, - label: variable.label, + label: variable.kind === 'image' ? `🖼 ${variable.label}` : variable.label, }))} value={block.variable} - onChange={(value) => onChange({ ...block, variable: value })} + onChange={selectVariable} searchable disabled={disabled} /> )} - onChange({ ...block, fontSize: Number(value) || 14 })} - min={4} - max={200} - disabled={disabled} - /> + {!isImage && ( + onChange({ ...block, fontSize: Number(value) || 14 })} + min={4} + max={200} + disabled={disabled} + /> + )} - - onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) - } - data={[ - { value: 'left', label: t('designer.alignLeft', 'Left') }, - { value: 'center', label: t('designer.alignCenter', 'Centre') }, - { value: 'right', label: t('designer.alignRight', 'Right') }, - ]} - /> + {!isImage && ( + <> + + onChange({ ...block, align: value as TemplateFieldPlacement['align'] }) + } + data={[ + { value: 'left', label: t('designer.alignLeft', 'Left') }, + { value: 'center', label: t('designer.alignCenter', 'Centre') }, + { value: 'right', label: t('designer.alignRight', 'Right') }, + { value: 'justify', label: t('designer.alignJustify', 'Justify') }, + ]} + /> - - onChange({ ...block, fontWeight: value as TemplateFieldPlacement['fontWeight'] }) - } - data={[ - { value: 'normal', label: t('designer.weightNormal', 'Normal') }, - { value: 'bold', label: t('designer.weightBold', 'Bold') }, - ]} - /> + + + + - onChange({ ...block, color: value })} - disabled={disabled} - format="hex" - /> + onChange({ ...block, color: value })} + disabled={disabled} + format="hex" + /> + + )} ); diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx index 8dec84dfe..5c19085fa 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,13 +1,17 @@ import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { useLocalized, type LicenseType } from '@ema-platform/api'; +import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api'; import { groupedTypeOptions } from '../config/designer'; interface Props { licenseTypes: LicenseType[]; typeId: string | null; onTypeChange: (id: string | null) => void; + /** The selected licence type's rank ladder — empty for non-CoC/CoP types. */ + ranks: Rank[]; + rankId: string | null; + onRankChange: (id: string | null) => void; validityMonths: number; onValidityChange: (months: number) => void; currentValidityMonths?: number | null; @@ -22,6 +26,9 @@ export function DesignerToolbar({ licenseTypes, typeId, onTypeChange, + ranks, + rankId, + onRankChange, validityMonths, onValidityChange, currentValidityMonths, @@ -49,6 +56,23 @@ export function DesignerToolbar({ w={340} /> + {/* CoC/CoP only — a rank can carry its own design (e.g. Master's + certificate differs from an OOW's). "Default" (null) is the design + every other rank under the type falls back to. */} + {ranks.length > 0 && ( + ({ value: op, label: op }))} + value={operator} + onChange={(v) => v && setOperator(v as Operator)} + allowDeselect={false} + /> + + {operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && ( + ({ + value: o.value, + label: localized(o.label) || o.value, + }))} + multiple={undefined} + value={null} + onChange={(v) => { + if (!v) return; + const current = (value.in ?? []) as string[]; + if (!current.includes(v)) setInValues([...current, v]); + }} + /> + )} + + {operator === 'in' && target?.field.type !== 'SELECT' && ( + + setInValues( + e.currentTarget.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + /> + )} + + + {operator === 'in' && (value.in?.length ?? 0) > 0 && ( + + {(value.in ?? []).map((v, i) => ( + setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))} + title={t('certReq.condition.removeValue', 'Click to remove')} + > + {String(v)} × + + ))} + + )} + + {!target && value.field && ( + + {t( + 'certReq.condition.unknownField', + 'This path is not a field in the current schema yet — it will still be saved as typed.', + )} + + )} + + ); +} + +const EMPTY_ARM: ConditionArm = { field: '', equals: '' }; + /** * Authors one `FieldCondition` (`showWhen` on a section/field, or * `conditionExpression` on a document requirement). @@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number | * SELECT field, the value picker switches to that field's own options * instead of free text — the condition can only ever reference an answer * that could actually be chosen. + * + * "Any of these" switches to authoring several single-field conditions whose + * OR is the real condition — needed when the same logical value can live on + * one of several mutually-exclusive fields (e.g. a rank split by + * department, see FieldCondition.anyOf). */ export function ConditionBuilder({ value, @@ -54,40 +253,35 @@ export function ConditionBuilder({ allowClear?: boolean; }) { const { t } = useTranslation(); - const localized = useLocalized(); const active = value !== null; - const operator = operatorOf(value ?? undefined) ?? 'equals'; - const target = targets.find((c) => c.path === value?.field); - const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet']; + const isAnyOf = Boolean(value?.anyOf); + const arms = (value?.anyOf ?? []) as ConditionArm[]; - function setField(field: string) { - onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) }); + function setArm(i: number, arm: ConditionArm) { + const next = arms.slice(); + next[i] = arm; + onChange({ anyOf: next }); } - function setOperator(next: Operator) { - if (!value?.field) return; - const base: ConditionValue = { field: value.field }; - if (next === 'isSet') base.isSet = true; - else if (next === 'in') base.in = []; - else if (next === 'notEquals') base.notEquals = ''; - else base.equals = ''; - onChange(base); + function addArm() { + onChange({ anyOf: [...arms, { ...EMPTY_ARM }] }); } - function setValueRaw(raw: string) { - if (!value?.field) return; - const coerced = coerce(raw, target?.field.type); - if (operator === 'equals') onChange({ field: value.field, equals: coerced }); - else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced }); + function removeArm(i: number) { + onChange({ anyOf: arms.filter((_, idx) => idx !== i) }); } - function setInValues(raws: string[]) { - if (!value?.field) return; - onChange({ - field: value.field, - in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], - }); + function toggleAnyOf(next: boolean) { + if (next) { + // Seed the list from whatever single condition already existed, so + // switching modes doesn't discard work in progress. + const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM }; + onChange({ anyOf: [seed] }); + } else { + // Same, in reverse — the first arm becomes the single condition. + onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' }); + } } return ( @@ -102,116 +296,58 @@ export function ConditionBuilder({ {active && ( - c.path)} - value={value?.field ?? ''} - onChange={setField} + checked={isAnyOf} + onChange={(e) => toggleAnyOf(e.currentTarget.checked)} /> - - ({ - value: o.value, - label: localized(o.label) || o.value, - }))} - value={String(value?.equals ?? value?.notEquals ?? '')} - onChange={(v) => v !== null && setValueRaw(v)} - /> - )} - - {operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && ( - target?.field.type === 'BOOLEAN' ? ( - setValueRaw(String(e.currentTarget.checked))} - /> - ) : ( - setValueRaw(e.currentTarget.value)} - /> - ) - )} - - {operator === 'in' && target?.field.type === 'SELECT' && ( -