Add README, customization snapshot, and snapshot/restore tooling
Provisioning now restores all GUI customizations on reset+reprovision: - create_pod_espocrm.sh: deploy the version-controlled espocrm-custom/ tree (CTag entity, layouts, i18n, clientDefs, custom views, custom CSS) into the pod, then chown www-data and rebuild. Replaces the earlier inline CSS-only step. Adds a live-phase cache rebuild so customizations and the client cacheTimestamp are refreshed on every run. - espocrm-custom/: snapshot of custom/ and client/custom/ (source of truth). - snapshot_espocrm_custom.sh: refresh the snapshot from a running pod. - readme.md: usage, first-time host setup, image-update and reset workflows. - Include the task/instruction notes and plan.md for reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,13 @@ BIND_DIR="$HOME/.local/share/$POD_NAME"
|
|||||||
DB_DATA_DIR="$BIND_DIR/mariadb" # -> /var/lib/mysql
|
DB_DATA_DIR="$BIND_DIR/mariadb" # -> /var/lib/mysql
|
||||||
ESPO_DATA_DIR="$BIND_DIR/espocrm" # -> /var/www/html
|
ESPO_DATA_DIR="$BIND_DIR/espocrm" # -> /var/www/html
|
||||||
|
|
||||||
|
# GUI customizations snapshot, version-controlled in this repo next to the script.
|
||||||
|
# Deployed into /var/www/html on every provisioning run so a reset+reprovision
|
||||||
|
# restores all customizations (CTag entity, layouts, i18n, clientDefs, custom
|
||||||
|
# views, custom CSS, ...). Refresh it after GUI changes with snapshot_espocrm_custom.sh.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CUSTOM_SRC="$SCRIPT_DIR/espocrm-custom" # contains custom/ and client/custom/
|
||||||
|
|
||||||
# systemd --user paths
|
# systemd --user paths
|
||||||
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
|
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
|
||||||
WEB_UNIT_FILE="$USER_SYSTEMD_DIR/container-${WEB_CTR}.service"
|
WEB_UNIT_FILE="$USER_SYSTEMD_DIR/container-${WEB_CTR}.service"
|
||||||
@@ -103,50 +110,22 @@ for i in $(seq 1 90); do
|
|||||||
done
|
done
|
||||||
echo "EspoCRM responded with HTTP $code (rc=$?)"
|
echo "EspoCRM responded with HTTP $code (rc=$?)"
|
||||||
|
|
||||||
# --- GUI customization: variable column widths for Markdown tables -------------
|
# --- Deploy all GUI customizations from the repo snapshot ---------------------
|
||||||
# Core CSS forces `table-layout: fixed; width: 100%` on `.complex-text table`,
|
# Copies the version-controlled custom/ and client/custom/ trees into the pod so
|
||||||
# giving every column an equal width. Register a custom CSS file via cssList so
|
# a reset+reprovision restores everything: the CTag entity (controller, scopes,
|
||||||
# those tables size columns to content (table-layout: auto). Created by this
|
# recordDefs, i18n), layouts, clientDefs (Tags +button removed, row-actions =
|
||||||
# provisioning script (on the persisted bind-mount) so it survives re-runs; the
|
# Unlink only), the signature image-button view, and the table-var-col CSS.
|
||||||
# rebuild step below refreshes the cached cssList metadata.
|
# Extract as container-root (--no-same-owner), then chown to the webserver user.
|
||||||
podman exec "$WEB_CTR" sh -lc 'mkdir -p /var/www/html/client/custom/css /var/www/html/custom/Espo/Custom/Resources/metadata/app'
|
# The rebuild step below refreshes the cached metadata (entityDefs, cssList, ...).
|
||||||
podman exec -i "$WEB_CTR" sh -c 'cat > /var/www/html/client/custom/css/table-var-col.css' <<'CSSEOF'
|
if [ -d "$CUSTOM_SRC" ]; then
|
||||||
/**
|
tar -C "$CUSTOM_SRC" -cf - custom client/custom \
|
||||||
* Variable column widths for Markdown tables in text fields.
|
| podman exec -i "$WEB_CTR" tar -C /var/www/html -xf - --no-same-owner
|
||||||
*
|
echo "Deployed GUI customizations from $CUSTOM_SRC (rc=$?)"
|
||||||
* EspoCRM core CSS forces `table-layout: fixed; width: 100%` on
|
podman exec "$WEB_CTR" chown -R www-data:www-data /var/www/html/custom /var/www/html/client/custom
|
||||||
* `.complex-text table`, which makes all columns equally wide.
|
|
||||||
* `auto` lets columns size to content instead.
|
|
||||||
*
|
|
||||||
* Registered via custom/Espo/Custom/Resources/metadata/app/client.json (cssList).
|
|
||||||
*/
|
|
||||||
.complex-text table {
|
|
||||||
table-layout: auto !important;
|
|
||||||
width: auto !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
CSSEOF
|
|
||||||
echo "Wrote client/custom/css/table-var-col.css (rc=$?)"
|
|
||||||
|
|
||||||
# Merge the CSS path into cssList (idempotent; preserves other keys/entries and
|
|
||||||
# keeps "__APPEND__" first). Uses the container's php so no host jq is required.
|
|
||||||
podman exec "$WEB_CTR" php -r '
|
|
||||||
$file = "/var/www/html/custom/Espo/Custom/Resources/metadata/app/client.json";
|
|
||||||
$css = "client/custom/css/table-var-col.css";
|
|
||||||
$data = is_file($file) ? json_decode(file_get_contents($file), true) : [];
|
|
||||||
if (!is_array($data)) { $data = []; }
|
|
||||||
$list = (isset($data["cssList"]) && is_array($data["cssList"])) ? $data["cssList"] : [];
|
|
||||||
$list = array_values(array_filter($list, function ($x) use ($css) { return $x !== "__APPEND__" && $x !== $css; }));
|
|
||||||
array_unshift($list, "__APPEND__");
|
|
||||||
$list[] = $css;
|
|
||||||
$data["cssList"] = $list;
|
|
||||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
|
||||||
'
|
|
||||||
echo "Registered table-var-col.css in cssList (rc=$?)"
|
|
||||||
|
|
||||||
# Match ownership of the other EspoCRM customization files (webserver user).
|
|
||||||
podman exec "$WEB_CTR" chown -R www-data:www-data /var/www/html/client/custom /var/www/html/custom/Espo/Custom/Resources/metadata
|
|
||||||
echo "Fixed ownership of customizations (rc=$?)"
|
echo "Fixed ownership of customizations (rc=$?)"
|
||||||
|
else
|
||||||
|
echo "WARNING: customization snapshot not found at $CUSTOM_SRC; skipping deploy."
|
||||||
|
fi
|
||||||
|
|
||||||
# Rebuild EspoCRM cache so customizations on the persisted bind-mount take effect
|
# Rebuild EspoCRM cache so customizations on the persisted bind-mount take effect
|
||||||
# and the client cacheTimestamp is bumped (forces browsers to reload custom views,
|
# and the client cacheTimestamp is bumped (forces browsers to reload custom views,
|
||||||
|
|||||||
105
espoImage-button-in-signature-editor.md
Normal file
105
espoImage-button-in-signature-editor.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
### Goal
|
||||||
|
|
||||||
|
Restore the image button (`espoImage`) in the EspoCRM **signature** editor toolbar. The signature field uses the core view `views/preferences/fields/signature`, whose `setupToolbar()` overrides the toolbar to `[["style",[…]],["color",["color"]],["height",["height"]],["table",["espoLink"]],["misc",[<codeview>,"fullscreen"]]]` — i.e. it drops the `espoImage` button that the generic wysiwyg field has. We want the image button back **only for the signature field**, without changing anything else.
|
||||||
|
|
||||||
|
Do **not** touch other fields, ACLs, the generic wysiwyg editor (email body / templates already have the image button), or any data.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
- EspoCRM **9.3.8** (Apache variant) runs as a **rootless Podman pod** `espocrm_pod` on this server. Web container is expected to be `espocrm_ctr` (confirm with `podman ps`).
|
||||||
|
- App root inside the web container: `/var/www/html`, runs as user **`www-data`**.
|
||||||
|
- Customizations live on the persisted `/var/www/html` bind-mount, same locations as the S47 task:
|
||||||
|
- Backend metadata: `/var/www/html/custom/Espo/Custom/Resources/metadata/…`
|
||||||
|
- Frontend custom views: `/var/www/html/client/custom/src/…` (referenced with the `custom:` prefix).
|
||||||
|
|
||||||
|
### Mechanism (from 9.3.8 source — confirm against this instance before relying on it)
|
||||||
|
|
||||||
|
- The signature field view `client/src/views/preferences/fields/signature.js` extends the wysiwyg field and **overrides** `setupToolbar()`. Its `this.toolbar` contains a group `['table', ['espoLink']]` — the link button but **not** `espoImage`.
|
||||||
|
- The generic wysiwyg field `client/src/views/fields/wysiwyg.js` registers the upload callback in `enableWysiwygMode()`: `onImageUpload` → `uploadInlineAttachment(file)` → creates an `Attachment` (`role: 'Inline Attachment'`) and inserts `<img src="?entryPoint=attachment&id=…">`. This callback is present regardless of the toolbar, so as soon as the `espoImage` button is in the toolbar, its upload path produces an attachment.
|
||||||
|
- `espoImage` is a registered summernote plugin (`client/src/helpers/misc/summernote-custom.js`): its button invokes `espoImage.show` → modal `views/wysiwyg/modals/insert-image`, whose **upload** path runs through `insertImagesOrCallback` → the `onImageUpload` callback above. (The modal's **URL** path inserts a plain remote `<img>` instead — not what we want.)
|
||||||
|
|
||||||
|
Verify before relying on it:
|
||||||
|
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr sh -lc "grep -n 'setupToolbar\|espoLink\|espoImage\|table' /var/www/html/client/src/views/preferences/fields/signature.js"
|
||||||
|
podman exec espocrm_ctr sh -lc "grep -n 'onImageUpload\|uploadInlineAttachment\|entryPoint=attachment' /var/www/html/client/src/views/fields/wysiwyg.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the reduced toolbar with `['table', ['espoLink']]` and the `onImageUpload`→attachment path. If the structure differs in this build, adjust the toolbar-patch below to match the real group names.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Confirm container.** `podman ps` → identify the web container (expected `espocrm_ctr`).
|
||||||
|
|
||||||
|
2. **Create the custom signature field view** on the persisted mount:
|
||||||
|
|
||||||
|
`/var/www/html/client/custom/src/views/preferences/fields/signature.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
define('custom:views/preferences/fields/signature',
|
||||||
|
['views/preferences/fields/signature'], (SignatureFieldView) => {
|
||||||
|
|
||||||
|
return class extends SignatureFieldView {
|
||||||
|
|
||||||
|
setupToolbar() {
|
||||||
|
// Build the reduced signature toolbar first …
|
||||||
|
super.setupToolbar();
|
||||||
|
|
||||||
|
// … then re-add the image button next to the link button.
|
||||||
|
this.toolbar = (this.toolbar || []).map(group => {
|
||||||
|
if (Array.isArray(group) && group[0] === 'table') {
|
||||||
|
const buttons = group[1] || [];
|
||||||
|
|
||||||
|
if (!buttons.includes('espoImage')) {
|
||||||
|
return ['table', [...buttons, 'espoImage']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the directory if needed:
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr sh -lc "mkdir -p /var/www/html/client/custom/src/views/preferences/fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
> If the `grep` in the verify step shows no group whose first element is `'table'`, fall back to simply appending a new group instead of the `.map(...)`: `this.toolbar.push(['insert', ['espoImage']]);` (place it before the final `['misc', …]` group if you want it left of code-view/fullscreen).
|
||||||
|
|
||||||
|
3. **Point the signature field at the custom view** via an entityDefs override. Merge into (create if absent):
|
||||||
|
|
||||||
|
`/var/www/html/custom/Espo/Custom/Resources/metadata/entityDefs/Preferences.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"signature": {
|
||||||
|
"view": "custom:views/preferences/fields/signature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**MERGE** — if the file already exists, preserve all other keys and only add `fields.signature.view`. Do not drop the field's `type` (it stays `wysiwyg`; metadata is merged on top of core). Produce valid JSON.
|
||||||
|
|
||||||
|
4. **Fix ownership** (as root in the container):
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr chown -R www-data:www-data /var/www/html/client/custom /var/www/html/custom/Espo/Custom/Resources/metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Clear cache / rebuild** (as the web user):
|
||||||
|
```
|
||||||
|
podman exec -u www-data espocrm_ctr php command.php rebuild
|
||||||
|
```
|
||||||
|
(If `command.php` is missing in this build, use `bin/command rebuild`; or Administration → Rebuild in the UI as admin.)
|
||||||
|
|
||||||
|
6. **Verify (hard reload Ctrl+Shift+R first).** Open Benutzereinstellungen → the „E-Mail Signatur" editor. The toolbar must now show a **Bild/Image button** (picture icon) next to the link button. Click it → a „Bild einfügen" dialog opens with a **file-upload** option (and a URL field). Nothing else in EspoCRM should have changed.
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
1. Delete `/var/www/html/client/custom/src/views/preferences/fields/signature.js`.
|
||||||
|
2. Remove the `fields.signature.view` key from `…/metadata/entityDefs/Preferences.json` (or delete the file if it now only contains that key).
|
||||||
|
3. Rebuild (step 5) and hard-reload the browser.
|
||||||
14
espocrm-custom/client/custom/css/table-var-col.css
Normal file
14
espocrm-custom/client/custom/css/table-var-col.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Variable column widths for Markdown tables in text fields.
|
||||||
|
*
|
||||||
|
* EspoCRM core CSS forces `table-layout: fixed; width: 100%` on
|
||||||
|
* `.complex-text table`, which makes all columns equally wide.
|
||||||
|
* `auto` lets columns size to content instead.
|
||||||
|
*
|
||||||
|
* Registered via custom/Espo/Custom/Resources/metadata/app/client.json (cssList).
|
||||||
|
*/
|
||||||
|
.complex-text table {
|
||||||
|
table-layout: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
1
espocrm-custom/client/custom/modules/dummy.txt
Normal file
1
espocrm-custom/client/custom/modules/dummy.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dummy
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
define('custom:views/preferences/fields/signature',
|
||||||
|
['views/preferences/fields/signature'], (SignatureFieldView) => {
|
||||||
|
|
||||||
|
return class extends SignatureFieldView {
|
||||||
|
|
||||||
|
setupToolbar() {
|
||||||
|
// Build the reduced signature toolbar first …
|
||||||
|
super.setupToolbar();
|
||||||
|
|
||||||
|
// … then re-add the image button next to the link button.
|
||||||
|
this.toolbar = (this.toolbar || []).map(group => {
|
||||||
|
if (Array.isArray(group) && group[0] === 'table') {
|
||||||
|
const buttons = group[1] || [];
|
||||||
|
|
||||||
|
if (!buttons.includes('espoImage')) {
|
||||||
|
return ['table', [...buttons, 'espoImage']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
define('custom:views/record/row-actions/tags-unlink-only',
|
||||||
|
['views/record/row-actions/relationship'], (RelationshipRowActionsView) => {
|
||||||
|
|
||||||
|
return class extends RelationshipRowActionsView {
|
||||||
|
|
||||||
|
getActionList() {
|
||||||
|
const list = [];
|
||||||
|
|
||||||
|
// Keep only the "Unlink" action (German UI: "Link entfernen").
|
||||||
|
// View / Edit / Remove are intentionally omitted.
|
||||||
|
if (!this.options.unlinkDisabled) {
|
||||||
|
list.push({
|
||||||
|
action: 'unlinkRelated',
|
||||||
|
label: 'Unlink',
|
||||||
|
data: {
|
||||||
|
id: this.model.id,
|
||||||
|
},
|
||||||
|
groupIndex: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve any explicitly configured extra row actions (none by default).
|
||||||
|
this.getAdditionalActionList().forEach(item => list.push(item));
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
2
espocrm-custom/custom/Espo/Custom/.htaccess
Normal file
2
espocrm-custom/custom/Espo/Custom/.htaccess
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Order Deny,Allow
|
||||||
|
Deny from all
|
||||||
7
espocrm-custom/custom/Espo/Custom/Controllers/CTag.php
Normal file
7
espocrm-custom/custom/Espo/Custom/Controllers/CTag.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Custom\Controllers;
|
||||||
|
|
||||||
|
class CTag extends \Espo\Core\Templates\Controllers\Base
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "إنشاء {الكيانTypeTranslated}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Създаване на Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Vytvořit Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Opret Tag "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"cAgentur": "Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cAgentur": "Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Tag erstellen"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"verkaufschancen": "Verkaufschancen",
|
||||||
|
"kontakte": "Kontakte",
|
||||||
|
"firmen": "Firmen"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"verkaufschancen": "Verkaufschancen",
|
||||||
|
"kontakte": "Kontakte",
|
||||||
|
"firmen": "Firmen"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"cTags": "Tags"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cTags": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"options": {
|
||||||
|
"stage": {
|
||||||
|
"Interessant": "Interessant",
|
||||||
|
"Abgelehnt": "Abgelehnt",
|
||||||
|
"Angefragt": "Angefragt",
|
||||||
|
"Agentur prüft": "Agentur prüft",
|
||||||
|
"Kunde prüft": "Kunde prüft",
|
||||||
|
"Identifiziert": "Identifiziert",
|
||||||
|
"Brief versendet": "Brief versendet",
|
||||||
|
"Anruf-Wiedervorlage": "Anruf-Wiedervorlage",
|
||||||
|
"Angerufen": "Angerufen",
|
||||||
|
"Ersttermin vereinbart": "Ersttermin vereinbart",
|
||||||
|
"Ersttermin durchgeführt": "Ersttermin durchgeführt",
|
||||||
|
"Angebot": "Angebot",
|
||||||
|
"Verhandlung": "Verhandlung",
|
||||||
|
"Unterschriftsreif": "Unterschriftsreif",
|
||||||
|
"Gewonnen": "Gewonnen",
|
||||||
|
"In Durchführung": "In Durchführung",
|
||||||
|
"Abgeschl. / Bestandsk.": "Abgeschl. / Bestandsk.",
|
||||||
|
"Verloren": "Verloren",
|
||||||
|
"Ghosted": "Ghosted"
|
||||||
|
},
|
||||||
|
"cLeadQuelle": {
|
||||||
|
"Inbound Agentur": "Inbound Agentur",
|
||||||
|
"Inbound Plattform-Lead": "Inbound Plattform-Lead",
|
||||||
|
"Aktive Plattform-Suche": "Aktive Plattform-Suche",
|
||||||
|
"Aktive Akquise": "Aktive Akquise",
|
||||||
|
"Empfehlung / Netzwerk": "Empfehlung / Netzwerk",
|
||||||
|
"Sonstiges": "Sonstiges"
|
||||||
|
},
|
||||||
|
"cVerlustgrund": {
|
||||||
|
"Agentur Absage": "Agentur Absage",
|
||||||
|
"Kunde Absage": "Kunde Absage",
|
||||||
|
"Keine Einigung": "Keine Einigung",
|
||||||
|
"Eigene Absage": "Eigene Absage",
|
||||||
|
"Sonstiges": "Sonstiges",
|
||||||
|
"Agentur Ghosted": "Agentur Ghosted",
|
||||||
|
"Kunde Ghosted": "Kunde Ghosted"
|
||||||
|
},
|
||||||
|
"cVerguetungsmodell": {
|
||||||
|
"Agentur Honorar": "Agentur Honorar",
|
||||||
|
"Direkt Festpreis": "Direkt Festpreis",
|
||||||
|
"Direkt Honorar": "Direkt Honorar",
|
||||||
|
"Direkt Nutzenbasiert": "Direkt Nutzenbasiert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"cLeadQuelle": "Lead-Quelle",
|
||||||
|
"cVerlustgrund": "Verlustgrund",
|
||||||
|
"cProjektlink": "Projektlink",
|
||||||
|
"cVerguetungsmodell": "Vergütungsmodell",
|
||||||
|
"cAccount1": "Über Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cAccount1": "Über Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Δημιουργία Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"cAgentur": "Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cAgentur": "Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Create Tag"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"verkaufschancen": "Verkaufschancen",
|
||||||
|
"kontakte": "Kontakte",
|
||||||
|
"firmen": "Firmen"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"verkaufschancen": "Verkaufschancen",
|
||||||
|
"kontakte": "Kontakte",
|
||||||
|
"firmen": "Firmen"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"cTags": "Tags"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cTags": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"scopeNames": {
|
||||||
|
"CTag": "Tag"
|
||||||
|
},
|
||||||
|
"scopeNamesPlural": {
|
||||||
|
"CTag": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"cLeadQuelle": "Lead-Quelle",
|
||||||
|
"cVerlustgrund": "Verlustgrund",
|
||||||
|
"cProjektlink": "Projektlink",
|
||||||
|
"cVerguetungsmodell": "Vergütungsmodell",
|
||||||
|
"cAccount1": "Über Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cLeadQuelle": {
|
||||||
|
"Inbound Agentur": "Inbound Agentur",
|
||||||
|
"Inbound Plattform-Lead": "Inbound Plattform-Lead",
|
||||||
|
"Aktive Plattform-Suche": "Aktive Plattform-Suche",
|
||||||
|
"Aktive Akquise": "Aktive Akquise",
|
||||||
|
"Empfehlung / Netzwerk": "Empfehlung / Netzwerk",
|
||||||
|
"Sonstiges": "Sonstiges"
|
||||||
|
},
|
||||||
|
"cVerlustgrund": {
|
||||||
|
"Agentur Absage": "Agentur Absage",
|
||||||
|
"Kunde Absage": "Kunde Absage",
|
||||||
|
"Keine Einigung": "Keine Einigung",
|
||||||
|
"Eigene Absage": "Eigene Absage",
|
||||||
|
"Sonstiges": "Sonstiges",
|
||||||
|
"Agentur Ghosted": "Agentur Ghosted",
|
||||||
|
"Kunde Ghosted": "Kunde Ghosted"
|
||||||
|
},
|
||||||
|
"cVerguetungsmodell": {
|
||||||
|
"Agentur Honorar": "Agentur Honorar",
|
||||||
|
"Direkt Festpreis": "Direkt Festpreis",
|
||||||
|
"Direkt Honorar": "Direkt Honorar",
|
||||||
|
"Direkt Nutzenbasiert": "Direkt Nutzenbasiert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cAccount1": "Über Agentur",
|
||||||
|
"cTags": "Tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Crear Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Crear Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "ایجاد Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Créer un Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Napravi Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "{EntityTypeTranslated} létrehozása"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Buat Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Crea Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Tag を作成する"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Sukurti Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Izveidot Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Opprett Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Creëer Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Utwórz Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Criar Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Criar Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Creare Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Создать Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Vytvoriť Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Ustvari Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Napravi Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Skapa Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "สร้าง Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Tag oluştur"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Створити Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "{entityTypetranslated} بنائیں"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "Tạo Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "创建 Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create CTag": "建立Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"activities": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"_delimiter_": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"_tabBreak_0": {
|
||||||
|
"index": 0,
|
||||||
|
"tabBreak": true,
|
||||||
|
"tabLabel": "$Stream"
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"sticked": false,
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
"_tabBreak_1": {
|
||||||
|
"index": 2,
|
||||||
|
"tabBreak": true,
|
||||||
|
"tabLabel": "$Account"
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"index": 3
|
||||||
|
},
|
||||||
|
"opportunities": {
|
||||||
|
"index": 4
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"index": 5
|
||||||
|
},
|
||||||
|
"_tabBreak_2": {
|
||||||
|
"index": 6,
|
||||||
|
"tabBreak": true,
|
||||||
|
"tabLabel": "$Support"
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"index": 7
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"index": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"_delimiter_": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"firmen": {
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"kontakte": {
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
"verkaufschancen": {
|
||||||
|
"index": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"activities": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"_delimiter_": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"sticked": false,
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"opportunities": {
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"index": 2
|
||||||
|
},
|
||||||
|
"targetLists": {
|
||||||
|
"index": 3
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"index": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"activities": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"_delimiter_": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"sticked": false,
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"index": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "stage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cLeadQuelle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "cVerguetungsmodell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amount"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "closeDate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cVerlustgrund"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"dynamicLogicVisible": null,
|
||||||
|
"style": "default",
|
||||||
|
"dynamicLogicStyled": null,
|
||||||
|
"tabBreak": false,
|
||||||
|
"hidden": false,
|
||||||
|
"noteText": null,
|
||||||
|
"customLabel": "Vorgang"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cAccount1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "contacts"
|
||||||
|
},
|
||||||
|
false
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"dynamicLogicVisible": null,
|
||||||
|
"style": "default",
|
||||||
|
"tabBreak": false,
|
||||||
|
"hidden": false,
|
||||||
|
"noteText": null,
|
||||||
|
"noteStyle": "info",
|
||||||
|
"customLabel": "Beteiligte"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "cProjektlink"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"dynamicLogicVisible": null,
|
||||||
|
"style": "default",
|
||||||
|
"tabBreak": false,
|
||||||
|
"tabLabel": null,
|
||||||
|
"hidden": false,
|
||||||
|
"noteText": null,
|
||||||
|
"noteStyle": "info",
|
||||||
|
"customLabel": "Projekt"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amount",
|
||||||
|
"align": "right",
|
||||||
|
"isLarge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cLeadQuelle"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cAccount1",
|
||||||
|
"width": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "account",
|
||||||
|
"width": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stage",
|
||||||
|
"width": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cLeadQuelle",
|
||||||
|
"width": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amount",
|
||||||
|
"width": 9,
|
||||||
|
"align": "right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cVerguetungsmodell",
|
||||||
|
"width": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "closeDate",
|
||||||
|
"width": 9,
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignedUser",
|
||||||
|
"width": 9
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"cssList": [
|
||||||
|
"__APPEND__",
|
||||||
|
"client/custom/css/table-var-col.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"relationshipPanels": {
|
||||||
|
"agentur": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"cAgentur": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"create": false,
|
||||||
|
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"controller": "controllers/record",
|
||||||
|
"boolFilterList": [
|
||||||
|
"onlyMy"
|
||||||
|
],
|
||||||
|
"relationshipPanels": {
|
||||||
|
"verkaufschancen": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"kontakte": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"firmen": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"relationshipPanels": {
|
||||||
|
"tags": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"create": false,
|
||||||
|
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"relationshipPanels": {
|
||||||
|
"account1": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"cAccount1": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"verkaufschancen": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"layout": null,
|
||||||
|
"selectPrimaryFilterName": null
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"create": false,
|
||||||
|
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"links": {
|
||||||
|
"cAgentur": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"foreign": "cAccount1",
|
||||||
|
"entity": "Opportunity",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"relationName": "cAccountTag",
|
||||||
|
"foreign": "firmen",
|
||||||
|
"entity": "CTag",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"name": {
|
||||||
|
"type": "varchar",
|
||||||
|
"required": true,
|
||||||
|
"pattern": "$noBadCharacters"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "datetime",
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
|
"modifiedAt": {
|
||||||
|
"type": "datetime",
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
|
"createdBy": {
|
||||||
|
"type": "link",
|
||||||
|
"readOnly": true,
|
||||||
|
"view": "views/fields/user"
|
||||||
|
},
|
||||||
|
"modifiedBy": {
|
||||||
|
"type": "link",
|
||||||
|
"readOnly": true,
|
||||||
|
"view": "views/fields/user"
|
||||||
|
},
|
||||||
|
"assignedUser": {
|
||||||
|
"type": "link",
|
||||||
|
"required": false,
|
||||||
|
"view": "views/fields/assigned-user"
|
||||||
|
},
|
||||||
|
"teams": {
|
||||||
|
"type": "linkMultiple",
|
||||||
|
"view": "views/fields/teams"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"createdBy": {
|
||||||
|
"type": "belongsTo",
|
||||||
|
"entity": "User"
|
||||||
|
},
|
||||||
|
"modifiedBy": {
|
||||||
|
"type": "belongsTo",
|
||||||
|
"entity": "User"
|
||||||
|
},
|
||||||
|
"assignedUser": {
|
||||||
|
"type": "belongsTo",
|
||||||
|
"entity": "User"
|
||||||
|
},
|
||||||
|
"teams": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"entity": "Team",
|
||||||
|
"relationName": "entityTeam",
|
||||||
|
"layoutRelationshipsDisabled": true
|
||||||
|
},
|
||||||
|
"verkaufschancen": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"relationName": "cOpportunityTag",
|
||||||
|
"foreign": "cTags",
|
||||||
|
"entity": "Opportunity",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"kontakte": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"relationName": "cContactTag",
|
||||||
|
"foreign": "cTags",
|
||||||
|
"entity": "Contact",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"firmen": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"relationName": "cAccountTag",
|
||||||
|
"foreign": "cTags",
|
||||||
|
"entity": "Account",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"orderBy": "createdAt",
|
||||||
|
"order": "desc"
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"name": {
|
||||||
|
"columns": [
|
||||||
|
"name",
|
||||||
|
"deleted"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"assignedUser": {
|
||||||
|
"columns": [
|
||||||
|
"assignedUserId",
|
||||||
|
"deleted"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"columns": [
|
||||||
|
"createdAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAtId": {
|
||||||
|
"unique": true,
|
||||||
|
"columns": [
|
||||||
|
"createdAt",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"links": {
|
||||||
|
"cTags": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"relationName": "cContactTag",
|
||||||
|
"foreign": "kontakte",
|
||||||
|
"entity": "CTag",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"stage": {
|
||||||
|
"options": [
|
||||||
|
"Interessant",
|
||||||
|
"Abgelehnt",
|
||||||
|
"Angefragt",
|
||||||
|
"Agentur prüft",
|
||||||
|
"Kunde prüft",
|
||||||
|
"Identifiziert",
|
||||||
|
"Brief versendet",
|
||||||
|
"Anruf-Wiedervorlage",
|
||||||
|
"Angerufen",
|
||||||
|
"Ersttermin vereinbart",
|
||||||
|
"Ersttermin durchgeführt",
|
||||||
|
"Angebot",
|
||||||
|
"Verhandlung",
|
||||||
|
"Unterschriftsreif",
|
||||||
|
"Gewonnen",
|
||||||
|
"In Durchführung",
|
||||||
|
"Abgeschl. / Bestandsk.",
|
||||||
|
"Verloren",
|
||||||
|
"Ghosted"
|
||||||
|
],
|
||||||
|
"default": "Interessant",
|
||||||
|
"probabilityMap": {
|
||||||
|
"Interessant": 5,
|
||||||
|
"Abgelehnt": 0,
|
||||||
|
"Angefragt": 15,
|
||||||
|
"Agentur prüft": 20,
|
||||||
|
"Kunde prüft": 30,
|
||||||
|
"Identifiziert": 5,
|
||||||
|
"Brief versendet": 8,
|
||||||
|
"Anruf-Wiedervorlage": 10,
|
||||||
|
"Angerufen": 12,
|
||||||
|
"Ersttermin vereinbart": 20,
|
||||||
|
"Ersttermin durchgeführt": 30,
|
||||||
|
"Angebot": 40,
|
||||||
|
"Verhandlung": 60,
|
||||||
|
"Unterschriftsreif": 80,
|
||||||
|
"Gewonnen": 100,
|
||||||
|
"In Durchführung": 100,
|
||||||
|
"Abgeschl. / Bestandsk.": 100,
|
||||||
|
"Verloren": 0,
|
||||||
|
"Ghosted": 0
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"Interessant": null,
|
||||||
|
"Abgelehnt": "info",
|
||||||
|
"Angefragt": null,
|
||||||
|
"Agentur prüft": null,
|
||||||
|
"Kunde prüft": null,
|
||||||
|
"Identifiziert": null,
|
||||||
|
"Brief versendet": "warning",
|
||||||
|
"Anruf-Wiedervorlage": "warning",
|
||||||
|
"Angerufen": "warning",
|
||||||
|
"Ersttermin vereinbart": "warning",
|
||||||
|
"Ersttermin durchgeführt": "warning",
|
||||||
|
"Angebot": null,
|
||||||
|
"Verhandlung": null,
|
||||||
|
"Unterschriftsreif": null,
|
||||||
|
"Gewonnen": "success",
|
||||||
|
"In Durchführung": "primary",
|
||||||
|
"Abgeschl. / Bestandsk.": "info",
|
||||||
|
"Verloren": "danger",
|
||||||
|
"Ghosted": "info"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cLeadQuelle": {
|
||||||
|
"type": "enum",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
"Inbound Agentur",
|
||||||
|
"Inbound Plattform-Lead",
|
||||||
|
"Aktive Plattform-Suche",
|
||||||
|
"Aktive Akquise",
|
||||||
|
"Empfehlung / Netzwerk",
|
||||||
|
"Sonstiges"
|
||||||
|
],
|
||||||
|
"style": {
|
||||||
|
"Inbound Agentur": null,
|
||||||
|
"Inbound Plattform-Lead": null,
|
||||||
|
"Aktive Plattform-Suche": null,
|
||||||
|
"Aktive Akquise": null,
|
||||||
|
"Empfehlung / Netzwerk": null,
|
||||||
|
"Sonstiges": null
|
||||||
|
},
|
||||||
|
"default": "Inbound Agentur",
|
||||||
|
"maxLength": 100,
|
||||||
|
"isCustom": true,
|
||||||
|
"displayAsLabel": true
|
||||||
|
},
|
||||||
|
"cVerlustgrund": {
|
||||||
|
"type": "enum",
|
||||||
|
"options": [
|
||||||
|
"Agentur Absage",
|
||||||
|
"Kunde Absage",
|
||||||
|
"Keine Einigung",
|
||||||
|
"Eigene Absage",
|
||||||
|
"Sonstiges",
|
||||||
|
"Agentur Ghosted",
|
||||||
|
"Kunde Ghosted"
|
||||||
|
],
|
||||||
|
"style": {
|
||||||
|
"Agentur Absage": null,
|
||||||
|
"Kunde Absage": null,
|
||||||
|
"Keine Einigung": null,
|
||||||
|
"Eigene Absage": null,
|
||||||
|
"Sonstiges": null,
|
||||||
|
"Agentur Ghosted": null,
|
||||||
|
"Kunde Ghosted": null
|
||||||
|
},
|
||||||
|
"default": "Kunde Absage",
|
||||||
|
"maxLength": 100,
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"cProjektlink": {
|
||||||
|
"type": "url",
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"cVerguetungsmodell": {
|
||||||
|
"type": "enum",
|
||||||
|
"options": [
|
||||||
|
"Agentur Honorar",
|
||||||
|
"Direkt Festpreis",
|
||||||
|
"Direkt Honorar",
|
||||||
|
"Direkt Nutzenbasiert"
|
||||||
|
],
|
||||||
|
"style": {
|
||||||
|
"Agentur Honorar": null,
|
||||||
|
"Direkt Festpreis": null,
|
||||||
|
"Direkt Honorar": null,
|
||||||
|
"Direkt Nutzenbasiert": null
|
||||||
|
},
|
||||||
|
"default": "Agentur Honorar",
|
||||||
|
"displayAsLabel": true,
|
||||||
|
"maxLength": 100,
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"cAccount1": {
|
||||||
|
"type": "link"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"closeDate": {
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"cAccount1": {
|
||||||
|
"type": "belongsTo",
|
||||||
|
"foreign": "cAgentur",
|
||||||
|
"entity": "Account",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
},
|
||||||
|
"cTags": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"relationName": "cOpportunityTag",
|
||||||
|
"foreign": "verkaufschancen",
|
||||||
|
"entity": "CTag",
|
||||||
|
"audited": false,
|
||||||
|
"isCustom": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"signature": {
|
||||||
|
"view": "custom:views/preferences/fields/signature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"cVerlustgrund": {
|
||||||
|
"visible": {
|
||||||
|
"conditionGroup": [
|
||||||
|
{
|
||||||
|
"type": "in",
|
||||||
|
"attribute": "stage",
|
||||||
|
"value": [
|
||||||
|
"Abgelehnt",
|
||||||
|
"Verloren",
|
||||||
|
"Ghosted"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"conditionGroup": [
|
||||||
|
{
|
||||||
|
"type": "in",
|
||||||
|
"attribute": "stage",
|
||||||
|
"value": [
|
||||||
|
"Abgelehnt",
|
||||||
|
"Verloren",
|
||||||
|
"Ghosted"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"required": {
|
||||||
|
"conditionGroup": [
|
||||||
|
{
|
||||||
|
"type": "in",
|
||||||
|
"attribute": "stage",
|
||||||
|
"value": [
|
||||||
|
"Gewonnen",
|
||||||
|
"In Durchführung",
|
||||||
|
"Abgeschl. / Bestandsk."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"duplicateWhereBuilderClassName": "Espo\\Classes\\DuplicateWhereBuilders\\General"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"entity": true,
|
||||||
|
"layouts": true,
|
||||||
|
"tab": true,
|
||||||
|
"acl": true,
|
||||||
|
"aclPortal": true,
|
||||||
|
"aclPortalLevelList": [
|
||||||
|
"all",
|
||||||
|
"account",
|
||||||
|
"contact",
|
||||||
|
"own",
|
||||||
|
"no"
|
||||||
|
],
|
||||||
|
"customizable": true,
|
||||||
|
"importable": true,
|
||||||
|
"notifications": true,
|
||||||
|
"stream": false,
|
||||||
|
"disabled": false,
|
||||||
|
"type": "Base",
|
||||||
|
"module": "Custom",
|
||||||
|
"object": true,
|
||||||
|
"isCustom": true
|
||||||
|
}
|
||||||
2
espocrm-custom/custom/Espo/Modules/.htaccess
Normal file
2
espocrm-custom/custom/Espo/Modules/.htaccess
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Order Deny,Allow
|
||||||
|
Deny from all
|
||||||
92
espocrm-table-var-col_claude-code-instructions.md
Normal file
92
espocrm-table-var-col_claude-code-instructions.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# EspoCRM Customization: Variable Column Widths for Markdown Tables
|
||||||
|
|
||||||
|
Instructions for Claude Code. Goal: integrate a custom CSS file into the EspoCRM provisioning so that Markdown tables in text fields get content-based column widths instead of equal fixed widths. The EspoCRM installation is generated by a shell script into a Podman pod, so **both files below must be created by the provisioning script** — manual edits inside the pod would be lost on the next provisioning run.
|
||||||
|
|
||||||
|
## Problem and Root Cause
|
||||||
|
|
||||||
|
EspoCRM renders Markdown in text fields (e.g. Opportunity → `Beschreibung`) inside `.complex-text` containers. The core stylesheet contains this rule (verified live on this instance, EspoCRM 9.3.8, 2026-06-06):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.alert table, .alert > .message table, .complex-text table,
|
||||||
|
.confirm-message table, .popover-content table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`table-layout: fixed` distributes the table width equally across all columns regardless of content. Measured effect on a 4-column table in a record detail view: every column 200 px wide, causing short columns (`Nr.`, `Bew.`) to waste space and the long `Anforderung` column to wrap into unnecessarily tall rows.
|
||||||
|
|
||||||
|
With `table-layout: auto`, columns size to their content. Verified by live style injection on the test record: column widths became 37 / 112 / 49 / 604 px — exactly the desired result.
|
||||||
|
|
||||||
|
## Solution: EspoCRM Custom CSS Mechanism
|
||||||
|
|
||||||
|
EspoCRM's official mechanism for custom stylesheets ([docs](https://docs.espocrm.com/development/custom-css/)): register the CSS file in the `cssList` metadata. Both involved directories (`client/custom/`, `custom/`) are upgrade-safe — EspoCRM upgrades do not touch them. The CSS filename is arbitrary; we use the declarative name `table-var-col.css`.
|
||||||
|
|
||||||
|
### File 1 (new): `<espocrm-root>/client/custom/css/table-var-col.css`
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* Variable column widths for Markdown tables in text fields.
|
||||||
|
*
|
||||||
|
* EspoCRM core CSS forces `table-layout: fixed; width: 100%` on
|
||||||
|
* `.complex-text table`, which makes all columns equally wide.
|
||||||
|
* `auto` lets columns size to content instead.
|
||||||
|
*
|
||||||
|
* Registered via custom/Espo/Custom/Resources/metadata/app/client.json (cssList).
|
||||||
|
*/
|
||||||
|
.complex-text table {
|
||||||
|
table-layout: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The selector deliberately targets only `.complex-text` (text fields on record views), not the alert/popover tables of the core rule.
|
||||||
|
- `!important` guards against load-order variations; `cssList` entries are appended after the theme stylesheet, but this makes the override robust either way.
|
||||||
|
|
||||||
|
### File 2 (new or merge): `<espocrm-root>/custom/Espo/Custom/Resources/metadata/app/client.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cssList": [
|
||||||
|
"__APPEND__",
|
||||||
|
"client/custom/css/table-var-col.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT — merge, do not overwrite:** This instance already has GUI customizations. If `client.json` already exists, append the path to the existing `cssList` array (keeping `"__APPEND__"` as its first element) and leave all other keys untouched. Check first.
|
||||||
|
|
||||||
|
## Integration into the Provisioning Script
|
||||||
|
|
||||||
|
1. Locate where the script already injects EspoCRM GUI customizations and follow the same convention (e.g. `podman cp`, volume mount, or heredoc).
|
||||||
|
2. Create both files after the EspoCRM installation step.
|
||||||
|
3. Match file ownership/permissions to the other EspoCRM application files (typically the webserver user inside the container, e.g. `www-data:www-data`). The CSS file must be readable by the webserver.
|
||||||
|
4. Clear the cache afterwards — `cssList` is metadata and is cached:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman exec <container> php /var/www/html/clear_cache.php
|
||||||
|
# or, if the script already performs a rebuild:
|
||||||
|
podman exec <container> php /var/www/html/rebuild.php
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt container name and EspoCRM root path to the script's actual values.)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. **Persistence test (the actual requirement):** re-run the provisioning script from scratch, then verify both files exist inside the pod:
|
||||||
|
`podman exec <container> ls -l /var/www/html/client/custom/css/table-var-col.css /var/www/html/custom/Espo/Custom/Resources/metadata/app/client.json`
|
||||||
|
2. Log in to the CRM, hard-reload (`Ctrl+F5`). In DevTools → Network, filter for `table-var-col` → the stylesheet must load with HTTP 200.
|
||||||
|
3. Open the test record (4-column Markdown table in `Beschreibung`):
|
||||||
|
`https://crm.creature-go.com/#Opportunity/view/6a23fc99e2391e7be`
|
||||||
|
4. Visual check: columns `Nr.`, `Einordnung`, `Bew.` at minimal width, `Anforderung` takes the remaining space; rows are one to two lines instead of uniformly tall.
|
||||||
|
5. Programmatic check in the DevTools console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
getComputedStyle(document.querySelector('.complex-text table')).tableLayout
|
||||||
|
// expected: "auto" (without the fix: "fixed")
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Regression check: open a record whose description contains no table and one with other Markdown (lists, headings) — rendering must be unchanged. Existing GUI customizations must still be active.
|
||||||
154
espocrm-tags-rowactions-unlink-only-claude-code.md
Normal file
154
espocrm-tags-rowactions-unlink-only-claude-code.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Task: Reduce the Tags relationship-panel row menu to "Unlink" only in EspoCRM
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
This EspoCRM instance has a custom many-to-many relationship to a custom `Tag` entity, exposed as a **"Tags" bottom relationship panel** on three entity types: **Opportunity**, **Contact**, and **Account**. On each of these three entities the link is named **`cTags`**. (The inline **Create (+)** button on these panels was already removed in a previous task via `relationshipPanels.cTags.create = false` — leave that in place.)
|
||||||
|
|
||||||
|
On a record's Tags panel, each attached tag row has a small dropdown (triangle/▾) that currently offers **four** actions: **View**, **Edit**, **Unlink**, **Remove** (German UI: **Ansehen**, **Bearbeiten**, **Link entfernen**, **Löschen**).
|
||||||
|
|
||||||
|
We want each tag row's dropdown to offer **only "Unlink"** (German: **"Link entfernen"**). The actions **View**, **Edit**, and **Remove/Delete** must be gone. This must apply to **all** users, including regular (non-admin) users, on **all three** entities (Opportunity, Contact, Account).
|
||||||
|
|
||||||
|
Do **not** change anything else (no ACLs, no other panels/fields/entities, and do not touch the `Tag` entity itself — creating, viewing, editing and deleting tags from the **Tag** entity's own list/detail views must keep working normally).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- EspoCRM (version **9.3.8**, Apache variant) runs as a **rootless Podman pod** named `espocrm_pod` on this server. The web container is expected to be `espocrm_ctr` (confirm with `podman ps`).
|
||||||
|
- EspoCRM application root inside the web container: `/var/www/html`. The app runs as user **`www-data`**.
|
||||||
|
- Customizations live under `/var/www/html/` and must be on the **persisted** bind-mount so they survive container recreation. Two locations are used here:
|
||||||
|
- Backend metadata: `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/` (this already holds `Opportunity.json`, `Contact.json`, `Account.json` from the +button task).
|
||||||
|
- Frontend custom view: `/var/www/html/client/custom/src/` (this is the official location for custom client-side views; it is part of the same persisted `/var/www/html` that already keeps your `custom/` metadata across restarts).
|
||||||
|
|
||||||
|
## Mechanism (verified against EspoCRM 9.3.8 source)
|
||||||
|
|
||||||
|
The per-row dropdown of a relationship panel is rendered by the view **`views/record/row-actions/relationship`**. Its `getActionList()` builds the menu like this (paraphrased from `client/src/views/record/row-actions/relationship.js`):
|
||||||
|
|
||||||
|
- **View** — pushed **unconditionally** (always present; there is **no** flag to disable it).
|
||||||
|
- **Edit** — pushed only if `this.options.acl.edit && !this.options.editDisabled`.
|
||||||
|
- **Unlink** — pushed only if `!this.options.unlinkDisabled`.
|
||||||
|
- **Remove** — pushed only if `this.options.acl.delete && !this.options.removeDisabled`.
|
||||||
|
|
||||||
|
The panel (`client/src/views/record/panels/relationship.js`) feeds these flags from the panel metadata and lets you swap the whole row-actions view:
|
||||||
|
|
||||||
|
```js
|
||||||
|
this.rowActionsView = this.defs.readOnly ? false : (this.defs.rowActionsView || this.rowActionsView);
|
||||||
|
// ...
|
||||||
|
rowActionsOptions: {
|
||||||
|
unlinkDisabled: unlinkDisabled,
|
||||||
|
editDisabled: this.defs.editDisabled,
|
||||||
|
removeDisabled: this.defs.removeDisabled,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Consequence:
|
||||||
|
- `editDisabled: true` and `removeDisabled: true` (in `clientDefs.<Entity>.relationshipPanels.cTags`) would hide **Edit** and **Remove** — but **not View**, because View is hard-coded.
|
||||||
|
- To leave **only Unlink**, we point the panel's **`rowActionsView`** (a supported `relationshipPanels.<link>` parameter) at a small **custom row-actions view** that returns only the Unlink action. This removes View, Edit and Remove in one go and is the clean, upgrade-safe approach.
|
||||||
|
|
||||||
|
Custom client views live under `client/custom/src/...` and are referenced with the **`custom:`** prefix (per the official EspoCRM docs, "Custom views"). No build step is required; a cache rebuild plus a hard browser reload picks them up.
|
||||||
|
|
||||||
|
### Confidence / self-verify before relying on it
|
||||||
|
|
||||||
|
The action name `unlinkRelated`, the four-action structure, and the `rowActionsView` parameter are taken from the 9.3.8 source. Before relying on them, confirm against this instance:
|
||||||
|
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr sh -lc "grep -n 'rowActionsView\|unlinkDisabled\|editDisabled\|removeDisabled' /var/www/html/client/src/views/record/panels/relationship.js"
|
||||||
|
podman exec espocrm_ctr sh -lc "grep -n 'quickView\|quickEdit\|unlinkRelated\|removeRelated' /var/www/html/client/src/views/record/row-actions/relationship.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see View pushed unconditionally, Unlink guarded by `unlinkDisabled`, and the panel reading `this.defs.rowActionsView`. If this version differs, adjust the custom view's `getActionList()` to match the real action name(s) for "Unlink".
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Confirm container and link name.**
|
||||||
|
- `podman ps` → identify the web container (expected `espocrm_ctr`).
|
||||||
|
- Confirm each of `Opportunity`, `Contact`, `Account` has a many-to-many link named `cTags`:
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr sh -lc "grep -rn 'cTags' /var/www/html/custom/Espo/Custom/Resources/metadata/entityDefs/"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create the custom row-actions view.** Create the directory and file (inside the web container, on the persisted `/var/www/html`):
|
||||||
|
|
||||||
|
`/var/www/html/client/custom/src/views/record/row-actions/tags-unlink-only.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
define('custom:views/record/row-actions/tags-unlink-only',
|
||||||
|
['views/record/row-actions/relationship'], (RelationshipRowActionsView) => {
|
||||||
|
|
||||||
|
return class extends RelationshipRowActionsView {
|
||||||
|
|
||||||
|
getActionList() {
|
||||||
|
const list = [];
|
||||||
|
|
||||||
|
// Keep only the "Unlink" action (German UI: "Link entfernen").
|
||||||
|
// View / Edit / Remove are intentionally omitted.
|
||||||
|
if (!this.options.unlinkDisabled) {
|
||||||
|
list.push({
|
||||||
|
action: 'unlinkRelated',
|
||||||
|
label: 'Unlink',
|
||||||
|
data: {
|
||||||
|
id: this.model.id,
|
||||||
|
},
|
||||||
|
groupIndex: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve any explicitly configured extra row actions (none by default).
|
||||||
|
this.getAdditionalActionList().forEach(item => list.push(item));
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure the directories exist, e.g.:
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr sh -lc "mkdir -p /var/www/html/client/custom/src/views/record/row-actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Point the three Tags panels at the custom view.** For `Opportunity`, `Contact`, and `Account`, add the `rowActionsView` key to the existing `cTags` panel in:
|
||||||
|
`/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/<EntityType>.json`
|
||||||
|
|
||||||
|
The resulting `cTags` block must look like this (note: **keep** the existing `"create": false` from the previous task):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relationshipPanels": {
|
||||||
|
"cTags": {
|
||||||
|
"create": false,
|
||||||
|
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Target files:
|
||||||
|
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Opportunity.json`
|
||||||
|
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Contact.json`
|
||||||
|
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Account.json`
|
||||||
|
- **MERGE into the existing JSON.** Preserve all other keys; in particular keep `relationshipPanels.cTags.create = false`. Only add the single `rowActionsView` key inside `relationshipPanels.cTags`. Do not overwrite the file. Produce valid JSON.
|
||||||
|
|
||||||
|
4. **Fix ownership** so the web user owns the new/changed files (run as root inside the container):
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr chown -R www-data:www-data /var/www/html/client/custom /var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Clear cache and rebuild** (run as the web user):
|
||||||
|
```
|
||||||
|
podman exec -u www-data espocrm_ctr php command.php rebuild
|
||||||
|
```
|
||||||
|
If `command.php` is not present in this version, use:
|
||||||
|
```
|
||||||
|
podman exec -u www-data espocrm_ctr bin/command rebuild
|
||||||
|
```
|
||||||
|
(Alternatively, an admin can run Administration → Rebuild in the web UI.)
|
||||||
|
|
||||||
|
6. **Verify in the browser** (do a hard reload, Ctrl+Shift+R, to drop the client cache; ideally test as a regular non-admin user, and also as admin):
|
||||||
|
- Open an **Opportunity**, a **Contact**, and an **Account** record. On the **Tags** panel, open the ▾ dropdown of an attached tag. It must now show **only "Link entfernen" (Unlink)**. **Ansehen / Bearbeiten / Löschen** (View / Edit / Remove) must be gone.
|
||||||
|
- **"Link entfernen"** must still work (it detaches the tag from the record but does not delete the Tag itself).
|
||||||
|
- The **Tag** entity's own list/detail views must still allow viewing, editing and deleting tags normally (this task does not touch the Tag entity or any ACL).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
1. Remove the `rowActionsView` key from `relationshipPanels.cTags` in the three `clientDefs` files (leave `create: false` if you still want the +button hidden).
|
||||||
|
2. Optionally delete `/var/www/html/client/custom/src/views/record/row-actions/tags-unlink-only.js`.
|
||||||
|
3. Rebuild again (step 5) and hard-reload the browser.
|
||||||
82
espocrm-tags-select-only-claude-code.md
Normal file
82
espocrm-tags-select-only-claude-code.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Task: Disable the inline "Create" button on the Tags relationship panels in EspoCRM
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
This EspoCRM instance has a custom many-to-many relationship to a custom `Tag` entity. It is exposed as a **"Tags" bottom relationship panel** on three entity types: **Opportunity**, **Contact**, and **Account**. On each of these three entities the link is named **`cTags`**.
|
||||||
|
|
||||||
|
Right now the Tags panel offers a **Create (+)** button plus a **three-dots (⋯) menu** that contains the actions **View list** and **Select**. We want to **remove only the inline Create (+) button** on these Tags panels, so that users can attach **existing** Tag records only (a controlled tag vocabulary), using the **⋯ → Select** action. Creating new tags must still be possible, but **only** via the `Tag` entity's own list view — never inline from a record's Tags panel. This must apply to **all** users, including regular (non-admin) users.
|
||||||
|
|
||||||
|
Do **not** change anything else (no other panels, fields, ACLs, or entities).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- EspoCRM runs as a **rootless Podman pod** named `espocrm_pod` on this server. The web container is expected to be `espocrm_ctr` (confirm with `podman ps`).
|
||||||
|
- EspoCRM application root inside the web container: `/var/www/html`. The app runs as user **`www-data`**.
|
||||||
|
- Customizations live under `/var/www/html/custom/Espo/Custom/Resources/metadata/` and survive upgrades. This path must be on the **persisted** volume/bind-mount so the change is not lost when the container is recreated.
|
||||||
|
|
||||||
|
## Mechanism
|
||||||
|
|
||||||
|
EspoCRM reads relationship-panel options from `clientDefs.<EntityType>.relationshipPanels.<linkName>`. Setting `"create": false` for a panel hides its Create (+) button while keeping the ⋯ menu actions (View list / Select). EspoCRM **deep-merges** metadata, so we add only this single key per entity and do not redefine the whole panel.
|
||||||
|
|
||||||
|
Confidence note on the key name: it is almost certainly `create`. Before relying on it, verify against this version's frontend source, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -rn "'create'\|\"create\"\|create:" /var/www/html/client/src/views/record/panels/relationship.js
|
||||||
|
grep -rn "relationshipPanels" /var/www/html/client/src/views/record/
|
||||||
|
```
|
||||||
|
|
||||||
|
Look at how the relationship panel decides whether to show the create action. If this version uses a different key (e.g. `createDisabled`), use the verified key instead and adjust the JSON below accordingly.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Confirm container and link name.**
|
||||||
|
- `podman ps` → identify the web container (expected `espocrm_ctr`).
|
||||||
|
- Confirm each of `Opportunity`, `Contact`, `Account` has a many-to-many link named `cTags` pointing to entity `Tag`, e.g.:
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr sh -lc "grep -rn 'cTags' /var/www/html/custom/Espo/Custom/Resources/metadata/entityDefs/"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add the override for each of the three entities.** For `Opportunity`, `Contact`, and `Account`, ensure the file
|
||||||
|
`/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/<EntityType>.json`
|
||||||
|
contains this key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relationshipPanels": {
|
||||||
|
"cTags": {
|
||||||
|
"create": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Target files:
|
||||||
|
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Opportunity.json`
|
||||||
|
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Contact.json`
|
||||||
|
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Account.json`
|
||||||
|
- **If a file already exists, MERGE this key into the existing JSON** (preserve all other keys; if `relationshipPanels` already exists, add `cTags` to it). Do not overwrite.
|
||||||
|
- If a file or the directory does not exist, create it. Produce valid JSON.
|
||||||
|
|
||||||
|
3. **Fix ownership** so the web user owns the new/changed files (run as root inside the container):
|
||||||
|
```
|
||||||
|
podman exec espocrm_ctr chown -R www-data:www-data /var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Clear cache and rebuild** (run as the web user):
|
||||||
|
```
|
||||||
|
podman exec -u www-data espocrm_ctr php command.php rebuild
|
||||||
|
```
|
||||||
|
If `command.php` is not present in this version, use:
|
||||||
|
```
|
||||||
|
podman exec -u www-data espocrm_ctr bin/command rebuild
|
||||||
|
```
|
||||||
|
(Alternatively, an admin can run Administration → Rebuild in the web UI.)
|
||||||
|
|
||||||
|
5. **Verify in the browser** (ideally as a regular non-admin user, and also as admin):
|
||||||
|
- Open an Opportunity, a Contact, and an Account record. The **Tags** panel must now have **no Create (+) button**. The three-dots (⋯) menu must still offer **View list** and **Select**, so existing tags can still be attached via **⋯ → Select**.
|
||||||
|
- The **Tag** entity's own list view must still have its Create button (deliberate tag creation still works there).
|
||||||
|
- Attaching an existing tag via **⋯ → Select** must still work.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Remove the added `relationshipPanels.cTags` key from the three `clientDefs` files (or delete the files if they contained only this key), then rebuild again.
|
||||||
158
plan.md
Normal file
158
plan.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Install EspoCRM on DesTEngSsv006 (rootless Podman pod)
|
||||||
|
|
||||||
|
Goal: create four shell scripts that run EspoCRM as a rootless Podman pod under the local user `mkt`.
|
||||||
|
|
||||||
|
A reference script, `create_pod_traefik.sh`, is attached. Follow its structure and conventions (the "House style" section below).
|
||||||
|
|
||||||
|
## Target environment (given — do not provision in the scripts)
|
||||||
|
|
||||||
|
- Host: DesTEngSsv006, Debian GNU/Linux 12 (bookworm), Kernel 6.1.0-49-amd64, x86-64.
|
||||||
|
- Podman 4.3.1, rootless. User `mkt` exists; subuid/subgid and `enable-linger` are configured. All scripts run as user `mkt`.
|
||||||
|
- Scripts live in `/home/mkt/bin` (a private git repo, not public, classified as a company secret). Plaintext passwords in the scripts are therefore deliberately acceptable.
|
||||||
|
- **Autostart:** Podman 4.3.1 has **no Quadlet** (introduced in 4.4). Use `podman generate systemd --files --new` (systemd `--user`) for autostart. Do not use Quadlet.
|
||||||
|
|
||||||
|
## House style (mandatory — follow `create_pod_traefik.sh`)
|
||||||
|
|
||||||
|
- Shebang `#!/bin/bash`, short comment header (who runs it, what it does).
|
||||||
|
- A block of environment variables at the top (pod name, container names, images, ports, IPs, `BIND_DIR`, systemd paths).
|
||||||
|
- Network option exactly as in the reference: `NET_OPTS='slirp4netns:allow_host_loopback=true,port_handler=slirp4netns'`.
|
||||||
|
- Data stored as bind mounts under `BIND_DIR="$HOME/.local/share/$POD_NAME"`, passed via `-v` in the `podman` command (no named volumes).
|
||||||
|
- Create the pod guarded with `if ! podman pod exists`; publish ports on the pod via `-p`.
|
||||||
|
- Create containers with `podman run -d --name ... --pod "$POD_NAME" -v ... "$IMAGE"`.
|
||||||
|
- Generate systemd units with `cd "$USER_SYSTEMD_DIR"; podman generate systemd --files --new --name "$POD_NAME"`.
|
||||||
|
- Then stop and remove the live pod, and `systemctl --user enable --now` the generated services (so the systemd-managed instance is what runs in the end).
|
||||||
|
- Status messages via `echo "... (rc=$?)"` as in the reference.
|
||||||
|
|
||||||
|
## Fixed parameters
|
||||||
|
|
||||||
|
- Pod name: `espocrm_pod`.
|
||||||
|
- Containers: `mariadb_ctr` (DB), `espocrm_ctr` (web), `espocrm_daemon_ctr` (scheduler/cron). **No WebSocket container** (no live updates).
|
||||||
|
- Images (pinned, no auto-updates):
|
||||||
|
- EspoCRM: `docker.io/espocrm/espocrm:9.3.8` (Apache variant, amd64).
|
||||||
|
- MariaDB: `docker.io/library/mariadb:11.4.12` (LTS, pinned patch version; separate from the EspoCRM image).
|
||||||
|
- Ports (loopback only):
|
||||||
|
- `127.0.0.1:8093` → EspoCRM web (container port 80).
|
||||||
|
- `127.0.0.1:8094` → MariaDB (container port 3306), for host-side DB access.
|
||||||
|
- MariaDB charset: utf8mb4, collation `utf8mb4_unicode_ci` (from the start, to avoid a later collation migration).
|
||||||
|
- `siteUrl`: `http://127.0.0.1:8093`.
|
||||||
|
- Admin user `admin`. DB name/user `espocrm`/`espocrm`. Passwords in plaintext in the scripts (private repo). Generate strong random passwords (e.g. `openssl rand -base64 24`) and set them as fixed variable values.
|
||||||
|
|
||||||
|
## Data storage (bind mounts)
|
||||||
|
|
||||||
|
```
|
||||||
|
BIND_DIR="$HOME/.local/share/espocrm_pod"
|
||||||
|
DB_DATA_DIR="$BIND_DIR/mariadb" # -> /var/lib/mysql
|
||||||
|
ESPO_DATA_DIR="$BIND_DIR/espocrm" # -> /var/www/html (app, data/upload, custom, config.php)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount the bind mounts plain, **without `:U` and without `:Z`**. Both official images run their entrypoint as root and chown their own data directories to the in-container service user before dropping privileges: MariaDB chowns `/var/lib/mysql` to `mysql`, EspoCRM chowns its writable resources (`data`, `custom`, `client/custom`, `install/config.php`) to `www-data`. In rootless Podman the container root maps to host user `mkt` and holds `CAP_CHOWN` inside the user namespace, so those chowns succeed on the bind-mounted host directories. No SELinux on Debian, so no `:Z`. This matches the other rootless pods on this host.
|
||||||
|
|
||||||
|
`espocrm_ctr` and `espocrm_daemon_ctr` share the same `ESPO_DATA_DIR` (both mount `/var/www/html`).
|
||||||
|
|
||||||
|
## Container configuration
|
||||||
|
|
||||||
|
### mariadb_ctr (start first)
|
||||||
|
Env variables:
|
||||||
|
```
|
||||||
|
MARIADB_ROOT_PASSWORD=<random>
|
||||||
|
MARIADB_DATABASE=espocrm
|
||||||
|
MARIADB_USER=espocrm
|
||||||
|
MARIADB_PASSWORD=<random>
|
||||||
|
```
|
||||||
|
Image arguments (utf8mb4) after the image name:
|
||||||
|
```
|
||||||
|
--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### espocrm_ctr (web, start after the DB)
|
||||||
|
Env variables:
|
||||||
|
```
|
||||||
|
ESPOCRM_DATABASE_PLATFORM=Mysql
|
||||||
|
ESPOCRM_DATABASE_HOST=127.0.0.1
|
||||||
|
ESPOCRM_DATABASE_PORT=3306
|
||||||
|
ESPOCRM_DATABASE_NAME=espocrm
|
||||||
|
ESPOCRM_DATABASE_USER=espocrm
|
||||||
|
ESPOCRM_DATABASE_PASSWORD=<random, identical to MARIADB_PASSWORD>
|
||||||
|
ESPOCRM_ADMIN_USERNAME=admin
|
||||||
|
ESPOCRM_ADMIN_PASSWORD=<random>
|
||||||
|
ESPOCRM_SITE_URL=http://127.0.0.1:8093
|
||||||
|
ESPOCRM_CONFIG_USE_WEB_SOCKET=false
|
||||||
|
```
|
||||||
|
Note: containers in a pod share the network namespace, so the DB is reachable at `127.0.0.1:3306` (not via the host port 8094). Use `127.0.0.1` instead of `localhost` to force TCP rather than a socket.
|
||||||
|
|
||||||
|
### espocrm_daemon_ctr (scheduler, start last)
|
||||||
|
- Same image `docker.io/espocrm/espocrm:9.3.8`.
|
||||||
|
- Start with `--entrypoint docker-daemon.sh` (cron/scheduler loop).
|
||||||
|
- Mounts the same `ESPO_DATA_DIR` at `/var/www/html`.
|
||||||
|
- Reads its configuration from the `data/config.php` written by `espocrm_ctr` during install, so start it after the web container.
|
||||||
|
|
||||||
|
## Script 1: create_pod_espocrm.sh (create plus autostart)
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Env-var block (see above) including the pinned images, ports, `NET_OPTS`, `BIND_DIR`, `USER_SYSTEMD_DIR="$HOME/.config/systemd/user"`, and the plaintext passwords.
|
||||||
|
2. Create directories: `mkdir -p "$DB_DATA_DIR" "$ESPO_DATA_DIR" "$USER_SYSTEMD_DIR"`.
|
||||||
|
3. Create the pod (guarded with `if ! podman pod exists`):
|
||||||
|
```
|
||||||
|
podman pod create -n "$POD_NAME" --network "$NET_OPTS" \
|
||||||
|
-p 127.0.0.1:8093:80 \
|
||||||
|
-p 127.0.0.1:8094:3306
|
||||||
|
```
|
||||||
|
4. Start `mariadb_ctr` (`podman rm -f` first for idempotency), with env, `-v "$DB_DATA_DIR":/var/lib/mysql`, then the utf8mb4 image arguments.
|
||||||
|
5. Wait for the DB to be ready:
|
||||||
|
```
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
podman exec "$DB_CTR" mariadb-admin ping --silent >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
```
|
||||||
|
6. Start `espocrm_ctr` (`podman rm -f` first), with env, `-v "$ESPO_DATA_DIR":/var/www/html`.
|
||||||
|
7. Wait for EspoCRM install/response (HTTP 200/302 on `http://127.0.0.1:8093/`, max ~3 min).
|
||||||
|
8. Start `espocrm_daemon_ctr` (`podman rm -f` first), with `--entrypoint docker-daemon.sh`, `-v "$ESPO_DATA_DIR":/var/www/html`.
|
||||||
|
9. Generate systemd units: `cd "$USER_SYSTEMD_DIR"; podman generate systemd --files --new --name "$POD_NAME"` (produces `pod-espocrm_pod.service` plus `container-mariadb_ctr.service`, `container-espocrm_ctr.service`, `container-espocrm_daemon_ctr.service`).
|
||||||
|
10. **Enforce start order** (like the readiness injection in `create_pod_traefik.sh`): insert an `ExecStartPre` wait loop into `container-espocrm_ctr.service` and `container-espocrm_daemon_ctr.service` that waits for the dependency it needs (web waits for DB port `127.0.0.1:8094`; daemon waits for web port `127.0.0.1:8093`). Insert via `awk`, as in the reference script.
|
||||||
|
11. Stop and remove the live pod: `podman pod stop --time 15 "$POD_NAME"; podman pod rm -f --ignore "$POD_NAME"`.
|
||||||
|
12. Enable the services:
|
||||||
|
```
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now pod-${POD_NAME}.service
|
||||||
|
systemctl --user enable --now container-mariadb_ctr.service
|
||||||
|
systemctl --user enable --now container-espocrm_ctr.service
|
||||||
|
systemctl --user enable --now container-espocrm_daemon_ctr.service
|
||||||
|
```
|
||||||
|
13. Print closing hints (status and log commands, as in the reference script).
|
||||||
|
|
||||||
|
## Script 2: stop_pod_espocrm.sh
|
||||||
|
|
||||||
|
- Stops the systemd-managed instance: `systemctl --user stop container-espocrm_daemon_ctr.service container-espocrm_ctr.service container-mariadb_ctr.service pod-${POD_NAME}.service` (in this order), with `echo` status messages.
|
||||||
|
- Do not disable (autostart stays in place), only stop.
|
||||||
|
|
||||||
|
## Script 3: reset_pod_espocrm.sh (dangerous — deletes all data)
|
||||||
|
|
||||||
|
- **Extra safety prompt**: only proceed after the exact confirmation phrase is entered, e.g.:
|
||||||
|
```
|
||||||
|
read -r -p "WARNING: this deletes the pod, containers, and ALL data under $BIND_DIR. To confirm, type 'RESET espocrm_pod': " C
|
||||||
|
[ "$C" = "RESET espocrm_pod" ] || { echo "Aborted."; exit 1; }
|
||||||
|
```
|
||||||
|
- Then: `systemctl --user disable --now` for the pod and all three containers; `podman pod rm -f --ignore "$POD_NAME"`; delete the generated unit files in `$USER_SYSTEMD_DIR`; `systemctl --user daemon-reload`; finally `rm -rf "$BIND_DIR"` (data gone).
|
||||||
|
- Print a hint that `create_pod_espocrm.sh` must be run again afterwards.
|
||||||
|
|
||||||
|
## Script 4: backup_espocrm.sh (no timer/cron)
|
||||||
|
|
||||||
|
- Backup target `BAK_DIR="$HOME/bak"` (`mkdir -p` at the start).
|
||||||
|
- Timestamp `TS=$(date +%Y%m%d-%H%M%S)`.
|
||||||
|
- DB dump via `podman exec` (so the backup script needs no password; it uses the container's own env):
|
||||||
|
```
|
||||||
|
podman exec "$DB_CTR" sh -c 'exec mariadb-dump --single-transaction --routines --triggers -uroot -p"$MARIADB_ROOT_PASSWORD" espocrm' | gzip > "$BAK_DIR/espocrm-db-$TS.sql.gz"
|
||||||
|
```
|
||||||
|
If `$MARIADB_ROOT_PASSWORD` is not available in the exec session, alternatively go host-side via the port: `mariadb-dump -h127.0.0.1 -P8094 -uroot -p<pw> ...` (password as a plaintext variable here too, same repo model). That is what port 8094 is for.
|
||||||
|
- File backup of the EspoCRM data (contains `config.php` with the crypt/passwordSalt key, `data/upload`, `custom`):
|
||||||
|
```
|
||||||
|
tar -czf "$BAK_DIR/espocrm-files-$TS.tar.gz" -C "$ESPO_DATA_DIR" .
|
||||||
|
```
|
||||||
|
- Closing message with the created file paths.
|
||||||
|
- No timer, no cron job (manual invocation).
|
||||||
|
|
||||||
|
## Sources for verifying env variable names and entrypoints
|
||||||
|
|
||||||
|
- Official image and tags: https://hub.docker.com/r/espocrm/espocrm
|
||||||
|
- Official Docker build repo (env variables, `docker-daemon.sh`): https://github.com/espocrm/espocrm-docker
|
||||||
276
readme.md
276
readme.md
@@ -1 +1,275 @@
|
|||||||
user mkt folder /home/mkt/bin git repository
|
# EspoCRM on DesTEngSsv006 — rootless Podman pod
|
||||||
|
|
||||||
|
This repository contains the shell scripts that run **EspoCRM** as a **rootless
|
||||||
|
Podman pod** under the local user `mkt`, plus the version-controlled snapshot of
|
||||||
|
all GUI customizations. Everything is driven by a handful of `*.sh` scripts; you
|
||||||
|
do not edit anything inside the running container by hand.
|
||||||
|
|
||||||
|
> This is a **private** repository classified as a company secret. Plaintext
|
||||||
|
> passwords live directly in `create_pod_espocrm.sh` on purpose — keep the repo
|
||||||
|
> private.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What gets deployed
|
||||||
|
|
||||||
|
`create_pod_espocrm.sh` builds one pod with three containers:
|
||||||
|
|
||||||
|
| Container | Image | Role |
|
||||||
|
|-----------------------|-----------------------------------------|---------------------------------------|
|
||||||
|
| `mariadb_ctr` | `docker.io/library/mariadb:11.4.12` | Database (utf8mb4 / utf8mb4_unicode_ci) |
|
||||||
|
| `espocrm_ctr` | `docker.io/espocrm/espocrm:9.3.8` | Web app (Apache) |
|
||||||
|
| `espocrm_daemon_ctr` | `docker.io/espocrm/espocrm:9.3.8` | Scheduler / cron loop |
|
||||||
|
|
||||||
|
Pod name: **`espocrm_pod`**. Images are **pinned** (no auto-updates).
|
||||||
|
|
||||||
|
**Ports — loopback only** (reach them via an SSH tunnel or a local reverse proxy):
|
||||||
|
|
||||||
|
| Host address | → | Container | Purpose |
|
||||||
|
|-----------------------|----|-----------|-------------------------------|
|
||||||
|
| `127.0.0.1:8093` | → | `:80` | EspoCRM web UI |
|
||||||
|
| `127.0.0.1:8094` | → | `:3306` | MariaDB (host-side DB access) |
|
||||||
|
|
||||||
|
Web UI: **http://127.0.0.1:8093** · Admin user: **`admin`** (password = the
|
||||||
|
`ESPOCRM_ADMIN_PASSWORD` value in `create_pod_espocrm.sh`).
|
||||||
|
|
||||||
|
**Data lives in bind mounts on the host** (not in named volumes):
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.local/share/espocrm_pod/mariadb -> /var/lib/mysql (database files)
|
||||||
|
~/.local/share/espocrm_pod/espocrm -> /var/www/html (app, uploads, config, customizations)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Autostart:** Podman 4.3.1 has no Quadlet, so the scripts generate
|
||||||
|
`systemd --user` units (`podman generate systemd --files --new`) and enable them.
|
||||||
|
The systemd-managed instance is what actually runs after provisioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The scripts at a glance
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|------------------------------|-----------------------------------------------------------------------------|
|
||||||
|
| `create_pod_espocrm.sh` | Create/recreate the pod, deploy GUI customizations, rebuild, enable autostart. **Safe to re-run.** |
|
||||||
|
| `stop_pod_espocrm.sh` | Stop the running containers + pod (autostart stays enabled). |
|
||||||
|
| `backup_espocrm.sh` | Write a timestamped DB dump + a tarball of the app data dir to `~/bak`. |
|
||||||
|
| `restore_espocrm.sh` | Restore a backup set (DB + files). `--list` shows sets, `--yes` skips the prompt. |
|
||||||
|
| `snapshot_espocrm_custom.sh` | Refresh the `espocrm-custom/` snapshot from the running pod after GUI edits. |
|
||||||
|
| `reset_pod_espocrm.sh` | **DANGER:** delete the pod, units **and all data**. Requires typing a phrase.|
|
||||||
|
| `create_pod_traefik.sh` | Unrelated reference script (Traefik pod) — kept as the house-style template. |
|
||||||
|
|
||||||
|
All scripts are run **as user `mkt`**, from this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/bin
|
||||||
|
./create_pod_espocrm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. First-time setup on a freshly installed host
|
||||||
|
|
||||||
|
The scripts assume the rootless-Podman groundwork is already in place. On a brand
|
||||||
|
new host, verify these **prerequisites** first (one-time, usually needs root):
|
||||||
|
|
||||||
|
1. **Podman installed** (4.3.x is what this was built for):
|
||||||
|
```bash
|
||||||
|
podman --version
|
||||||
|
```
|
||||||
|
2. **User `mkt` exists** and has **subuid/subgid ranges** (needed for rootless
|
||||||
|
user-namespace mapping):
|
||||||
|
```bash
|
||||||
|
grep '^mkt:' /etc/subuid /etc/subgid # each should print a line
|
||||||
|
# if missing (as root): usermod --add-subuids 524288-589823 --add-subgids 524288-589823 mkt
|
||||||
|
```
|
||||||
|
3. **Linger enabled** for `mkt`, so the user's systemd services start at boot and
|
||||||
|
keep running without an active login:
|
||||||
|
```bash
|
||||||
|
loginctl show-user mkt -p Linger # want: Linger=yes
|
||||||
|
# if not: sudo loginctl enable-linger mkt
|
||||||
|
```
|
||||||
|
4. **A user systemd session is reachable.** When working over plain SSH you may
|
||||||
|
need:
|
||||||
|
```bash
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||||
|
systemctl --user status # should respond
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, as `mkt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/bin
|
||||||
|
./create_pod_espocrm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
On this **first run** the script will:
|
||||||
|
|
||||||
|
- pull the pinned images,
|
||||||
|
- run the EspoCRM installer against an empty database (creates the schema and the
|
||||||
|
`admin` user from the credentials in the script),
|
||||||
|
- deploy all GUI customizations from `espocrm-custom/`,
|
||||||
|
- run a cache rebuild,
|
||||||
|
- generate and enable the `systemd --user` services (autostart).
|
||||||
|
|
||||||
|
When it finishes, open **http://127.0.0.1:8093** and log in as `admin`.
|
||||||
|
|
||||||
|
> Reaching the UI from your workstation: it only listens on loopback. Use an SSH
|
||||||
|
> tunnel, e.g. `ssh -L 8093:127.0.0.1:8093 mkt@DesTEngSsv006`, then browse to
|
||||||
|
> `http://127.0.0.1:8093` locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Everyday operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start (after a stop) — the systemd units stay enabled, so this also happens on boot:
|
||||||
|
systemctl --user start pod-espocrm_pod.service \
|
||||||
|
container-mariadb_ctr.service container-espocrm_ctr.service container-espocrm_daemon_ctr.service
|
||||||
|
|
||||||
|
# Stop everything (autostart remains enabled):
|
||||||
|
./stop_pod_espocrm.sh
|
||||||
|
|
||||||
|
# Status:
|
||||||
|
systemctl --user status pod-espocrm_pod.service
|
||||||
|
systemctl --user status container-espocrm_ctr.service
|
||||||
|
|
||||||
|
# Live logs:
|
||||||
|
journalctl --user -u container-espocrm_ctr.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-running `./create_pod_espocrm.sh` at any time is safe: it recreates the
|
||||||
|
containers from the persisted data, redeploys customizations, and rebuilds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Backups and restore
|
||||||
|
|
||||||
|
Backups are **manual** (no timer/cron). Each run writes two timestamped files to
|
||||||
|
`~/bak`: a gzipped SQL dump and a tarball of the app data dir (which includes
|
||||||
|
`config.php` with the crypt key, uploads, and customizations).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./backup_espocrm.sh # creates ~/bak/espocrm-db-<TS>.sql.gz and ...-files-<TS>.tar.gz
|
||||||
|
|
||||||
|
./restore_espocrm.sh --list # list available backup sets
|
||||||
|
./restore_espocrm.sh # restore the latest set (asks for confirmation)
|
||||||
|
./restore_espocrm.sh 20260606-1346 # restore a specific set by timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
`restore_espocrm.sh` checks the archives, drops & re-imports the database, and
|
||||||
|
replaces the app data dir, then rebuilds. **Take a backup before any risky
|
||||||
|
operation** (image update, reset, major change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. GUI customizations (important workflow)
|
||||||
|
|
||||||
|
All customizations made through the EspoCRM GUI (Entity Manager, Layout Manager,
|
||||||
|
Label Manager, custom fields/views, custom CSS, …) are stored in
|
||||||
|
**`espocrm-custom/`** in this repo and deployed by `create_pod_espocrm.sh`. This
|
||||||
|
snapshot is the **single source of truth** — a reset + reprovision restores it.
|
||||||
|
|
||||||
|
Current customizations captured here include the `CTag` tag entity and its
|
||||||
|
relationships, the Tags panels (Create `+` button removed, row menu reduced to
|
||||||
|
*Unlink*), the signature-editor image button, and variable-width Markdown tables.
|
||||||
|
|
||||||
|
**The golden rule:** after you change anything in the EspoCRM GUI, capture it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./snapshot_espocrm_custom.sh # pull current customizations into espocrm-custom/
|
||||||
|
git add espocrm-custom && git commit -m "Update EspoCRM customizations snapshot"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you skip this, the next `create_pod_espocrm.sh` run will **revert** your
|
||||||
|
un-captured GUI changes back to whatever is in `espocrm-custom/`.
|
||||||
|
|
||||||
|
> Note: customizations are *files*. Your actual records (tags, opportunities,
|
||||||
|
> contacts, …) are *data* and live only in the database — protect those with
|
||||||
|
> `backup_espocrm.sh`, not the snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Updating the container images
|
||||||
|
|
||||||
|
Images are pinned, so updates are deliberate. **Always back up first.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./backup_espocrm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### EspoCRM (e.g. 9.3.8 → a newer 9.x)
|
||||||
|
|
||||||
|
1. Edit the version in `create_pod_espocrm.sh` (the web and daemon share the same
|
||||||
|
`ESPO_IMAGE` variable, so one change covers both):
|
||||||
|
```bash
|
||||||
|
ESPO_IMAGE='docker.io/espocrm/espocrm:<new-version>'
|
||||||
|
```
|
||||||
|
2. Pull and reprovision:
|
||||||
|
```bash
|
||||||
|
podman pull docker.io/espocrm/espocrm:<new-version>
|
||||||
|
./create_pod_espocrm.sh
|
||||||
|
```
|
||||||
|
The EspoCRM container entrypoint **auto-runs its upgrade** when the image
|
||||||
|
version is newer than the installed version (it migrates the database and app
|
||||||
|
files on the bind mount). The script then redeploys customizations and
|
||||||
|
rebuilds.
|
||||||
|
3. Verify the UI and your customizations. If you tweaked anything in the GUI as
|
||||||
|
part of the upgrade, re-run `./snapshot_espocrm_custom.sh` and commit.
|
||||||
|
|
||||||
|
### MariaDB (e.g. 11.4.12 → a newer 11.4.x patch)
|
||||||
|
|
||||||
|
Patch/minor updates **within the same 11.4 LTS series** are data-compatible:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# edit DB_IMAGE in create_pod_espocrm.sh, then:
|
||||||
|
podman pull docker.io/library/mariadb:<new-11.4.x>
|
||||||
|
./create_pod_espocrm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a **major** MariaDB jump (e.g. 11.x → 12.x) do not just swap the tag —
|
||||||
|
back up, then dump/restore into a fresh data dir, and test. When in doubt, take
|
||||||
|
a backup and try it on a throwaway copy first.
|
||||||
|
|
||||||
|
### After any image change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman ps # all three containers Up
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8093/ # expect 200
|
||||||
|
podman image prune # optionally drop the old images
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the bumped image tag(s) in `create_pod_espocrm.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Resetting / uninstalling
|
||||||
|
|
||||||
|
`reset_pod_espocrm.sh` **destroys the pod, the systemd units, and ALL data**
|
||||||
|
(database + uploads). It asks you to type `RESET espocrm_pod` to proceed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./backup_espocrm.sh # do this first if you might want the data back
|
||||||
|
./reset_pod_espocrm.sh
|
||||||
|
./create_pod_espocrm.sh # fresh install; customizations are restored from espocrm-custom/
|
||||||
|
# (optional) restore your data:
|
||||||
|
./restore_espocrm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
After a reset, a fresh `create_pod_espocrm.sh` gives you a clean install with all
|
||||||
|
**customizations** back in place; **records** come back only via `restore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
create_pod_espocrm.sh # provision + autostart + deploy customizations
|
||||||
|
stop_pod_espocrm.sh # stop the pod
|
||||||
|
backup_espocrm.sh # back up DB + files to ~/bak
|
||||||
|
restore_espocrm.sh # restore a backup set
|
||||||
|
snapshot_espocrm_custom.sh # refresh espocrm-custom/ from the running pod
|
||||||
|
reset_pod_espocrm.sh # DANGER: delete pod + units + all data
|
||||||
|
espocrm-custom/ # version-controlled GUI customizations (source of truth)
|
||||||
|
create_pod_traefik.sh # unrelated reference script (house-style template)
|
||||||
|
*.md # task/instruction notes and this readme
|
||||||
|
```
|
||||||
|
|||||||
27
snapshot_espocrm_custom.sh
Executable file
27
snapshot_espocrm_custom.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# To be run by user mkt. Refreshes the version-controlled GUI-customization
|
||||||
|
# snapshot ($CUSTOM_SRC) from the running EspoCRM pod. Run this after making
|
||||||
|
# customizations in the EspoCRM GUI (Entity Manager, Layout Manager, Label
|
||||||
|
# Manager, etc.) so create_pod_espocrm.sh can restore them on reset+reprovision.
|
||||||
|
# Commit the result to the repo afterwards.
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
WEB_CTR='espocrm_ctr'
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CUSTOM_SRC="$SCRIPT_DIR/espocrm-custom" # contains custom/ and client/custom/
|
||||||
|
|
||||||
|
if ! podman exec "$WEB_CTR" true 2>/dev/null; then
|
||||||
|
echo "ERROR: container $WEB_CTR is not running. Start the pod first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pull /var/www/html/{custom,client/custom} into a clean snapshot dir.
|
||||||
|
rm -rf "$CUSTOM_SRC"
|
||||||
|
mkdir -p "$CUSTOM_SRC"
|
||||||
|
podman exec "$WEB_CTR" tar -C /var/www/html -cf - custom client/custom \
|
||||||
|
| tar -C "$CUSTOM_SRC" -xf - --no-same-owner
|
||||||
|
echo "Refreshed snapshot at $CUSTOM_SRC (rc=$?)"
|
||||||
|
|
||||||
|
echo "Files: $(find "$CUSTOM_SRC" -type f | wc -l)"
|
||||||
|
echo "Review and commit, e.g.: git -C \"$SCRIPT_DIR\" add espocrm-custom && git -C \"$SCRIPT_DIR\" commit -m 'Update EspoCRM customizations snapshot'"
|
||||||
Reference in New Issue
Block a user