Public Access
Part 1 — asset versioning: every local <script src> and <link rel=stylesheet> on the tracked pages now carries ?v=<sha256-of-the-file> (10 hex), computed at BUILD time from the file's bytes by work/build-asset-versions.py — never hand-typed, and idempotent (re-running with no file change rewrites nothing). This is what makes an edited focus-rule.js / photo-upload.js a new URL instead of a stale cache hit. Part 2 — language facet: a language now counts for the Language filter only when its 0-5 self-assessment is >= LANG_FACET_MIN (3 = 'works in it'). The constant sits beside the 15-point rule in the page script and its mirror in build-directory.py. Nothing stored changes; the card's languages chart still prints every language above 0, the search box is unchanged, and the facet stays an exact membership test against the card's value. A '1' language no longer offers the person under that language.
1068 lines
44 KiB
HTML
1068 lines
44 KiB
HTML
<!doctype html>
|
|
<html lang="en-US">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Freelancer records — admin</title>
|
|
<!-- DRAFT / INTERNAL: not indexed, not linked, not in sitemap.xml / llms.txt.
|
|
Reachable only by this URL. All five draft conditions hold
|
|
(noindex · not in sitemap · not in llms.txt · no inbound links · no announcement
|
|
surface). Do NOT link to this page from anywhere — not from the directory, not
|
|
from the footer. Remove this robots meta only if it ever becomes public. -->
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
<link rel="canonical" href="https://odoo4projects.com/odoo-community-developers/admin/" />
|
|
<link rel="icon" href="/images/favicon.ico" />
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
|
|
media="print"
|
|
onload="this.media='all'"
|
|
/>
|
|
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
|
|
<link rel="stylesheet" href="/style.css?v=3332fbba93" />
|
|
<style>
|
|
/* ---- Freelancer records — admin (page-scoped). Internal page, never linked. ---- */
|
|
.adm-bar {
|
|
background: var(--midnight-2);
|
|
border-bottom: 1px solid var(--line-brass);
|
|
padding: 0.85rem 0;
|
|
}
|
|
.adm-bar .container {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.adm-brand {
|
|
font-family: Marcellus, Georgia, serif;
|
|
font-size: 1.15rem;
|
|
letter-spacing: 0.04em;
|
|
color: #fff;
|
|
}
|
|
.adm-brand span {
|
|
color: var(--brass);
|
|
}
|
|
.adm-tag {
|
|
font-size: 0.78rem;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
color: rgba(255, 255, 255, 0.65);
|
|
}
|
|
|
|
.adm-wrap {
|
|
padding: 2.5rem 0 4rem;
|
|
}
|
|
.adm-wrap h1 {
|
|
font-family: Marcellus, Georgia, serif;
|
|
font-weight: 400;
|
|
font-size: 1.9rem;
|
|
color: var(--midnight);
|
|
margin: 0 0 0.6rem;
|
|
}
|
|
.adm-note {
|
|
max-width: 720px;
|
|
margin: 0 0 1.75rem;
|
|
color: var(--ink-soft);
|
|
line-height: 1.7;
|
|
}
|
|
.adm-note code {
|
|
font-size: 0.85em;
|
|
color: var(--midnight);
|
|
}
|
|
|
|
.form-card {
|
|
max-width: 860px;
|
|
background: var(--ivory);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.5rem;
|
|
box-shadow: var(--shadow-rest);
|
|
}
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1rem;
|
|
}
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.form-field.full,
|
|
.form-grid > .full {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.form-field label {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--midnight);
|
|
}
|
|
.form-field .hint {
|
|
font-size: 0.78rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
.form-field input,
|
|
.form-field textarea,
|
|
.form-field select {
|
|
font-family: Inter, sans-serif;
|
|
font-size: 0.9rem;
|
|
padding: 0.6rem 0.7rem;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--line);
|
|
background: #fff;
|
|
color: var(--ink);
|
|
width: 100%;
|
|
}
|
|
/* The standardised language multi-select: a closed, scrollable checklist. The
|
|
level <select> appears beside a language only once it is checked — pure CSS. */
|
|
.lang-field {
|
|
min-width: 0;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
padding: 0.85rem 0.9rem 0.6rem;
|
|
}
|
|
.lang-field legend {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--midnight);
|
|
padding: 0 0.35rem;
|
|
}
|
|
.lang-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
max-height: 220px;
|
|
overflow: auto;
|
|
padding-right: 0.35rem;
|
|
}
|
|
.lang-pick {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.88rem;
|
|
color: var(--ink);
|
|
}
|
|
.lang-pick input {
|
|
width: auto;
|
|
margin: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
.lang-pick .lang-level {
|
|
display: none;
|
|
width: auto;
|
|
max-width: 9rem;
|
|
padding: 0.25rem 0.4rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.lang-pick input:checked ~ .lang-level {
|
|
display: inline-block;
|
|
}
|
|
.form-check {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.6rem;
|
|
font-size: 0.85rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
.form-check input {
|
|
width: auto;
|
|
margin-top: 0.2rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.form-actions {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.form-status {
|
|
font-size: 0.85rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
.form-status[data-state="ok"] {
|
|
color: var(--success, #3f7d4f);
|
|
}
|
|
.form-status[data-state="err"] {
|
|
color: #b3452f;
|
|
}
|
|
.adm-loaded {
|
|
grid-column: 1 / -1;
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
color: var(--ink-soft);
|
|
}
|
|
.adm-loaded[data-state="ok"] {
|
|
color: var(--success, #3f7d4f);
|
|
}
|
|
.adm-loaded[data-state="err"] {
|
|
color: #b3452f;
|
|
}
|
|
.adm-sep {
|
|
grid-column: 1 / -1;
|
|
width: 100%;
|
|
border: 0;
|
|
border-top: 1px solid var(--line);
|
|
margin: 0.35rem 0;
|
|
}
|
|
/* Page-local quiet button: the shared .btn-outline is cream-on-transparent (built
|
|
for the dark sections) and would be near-invisible on this page's ivory card. */
|
|
.btn-quiet {
|
|
background: transparent;
|
|
color: var(--ink);
|
|
border-color: rgba(23, 28, 38, 0.22);
|
|
}
|
|
.btn-quiet:hover {
|
|
color: var(--brass-deep);
|
|
border-color: var(--brass-deep);
|
|
background: rgba(201, 162, 39, 0.08);
|
|
}
|
|
.btn:focus-visible {
|
|
outline: 3px solid var(--brass-deep);
|
|
outline-offset: 2px;
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
/* ---- Focus + language sliders (2026-11-03n) ---- */
|
|
.focus-field {
|
|
min-width: 0;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
padding: 0.85rem 0.9rem 0.7rem;
|
|
}
|
|
.focus-field legend {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--midnight);
|
|
padding: 0 0.35rem;
|
|
}
|
|
.focus-axis {
|
|
display: grid;
|
|
grid-template-columns: 6.5rem 1fr 2.2rem;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
margin: 0.3rem 0;
|
|
}
|
|
.focus-axis label {
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: var(--ink);
|
|
}
|
|
.focus-axis input[type="range"] {
|
|
width: 100%;
|
|
accent-color: var(--brass-deep);
|
|
}
|
|
.focus-axis output {
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: 0.9rem;
|
|
text-align: right;
|
|
color: var(--ink);
|
|
}
|
|
.focus-total {
|
|
margin: 0.6rem 0 0;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--midnight);
|
|
}
|
|
.focus-scale {
|
|
margin: 0.45rem 0 0;
|
|
font-size: 0.76rem;
|
|
line-height: 1.55;
|
|
color: var(--ink-soft);
|
|
}
|
|
.focus-warn {
|
|
margin: 0.55rem 0 0;
|
|
font-size: 0.85rem;
|
|
color: #b3452f;
|
|
min-height: 1.1em;
|
|
}
|
|
.focus-counter {
|
|
font-size: 0.78rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
.focus-counter[data-over="1"] {
|
|
color: #b3452f;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.focus-axis {
|
|
grid-template-columns: 5.2rem 1fr 1.7rem;
|
|
gap: 0.4rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="adm-bar">
|
|
<div class="container">
|
|
<span class="adm-brand">ODOO4<span>projects</span></span>
|
|
<span class="adm-tag">Freelancer records — internal</span>
|
|
</div>
|
|
</div>
|
|
|
|
<main class="adm-wrap">
|
|
<div class="container">
|
|
<h1>Add or correct a freelancer record</h1>
|
|
<p class="adm-note">
|
|
Internal page — not linked from the directory. Add a new record, or load an existing one by
|
|
its id, <strong>confirm it is the right person</strong>, then correct what is wrong. A save
|
|
posts to the one <code>odoo_experts</code> webhook and is applied after email confirmation.
|
|
Fields you leave alone keep the record's current value.
|
|
</p>
|
|
|
|
<form class="form-card" id="adminForm" novalidate>
|
|
<div class="form-grid">
|
|
<div class="form-field">
|
|
<label for="adId">Record id <span class="hint">(leave blank to create a new record)</span></label>
|
|
<input id="adId" name="id" type="number" min="1" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adLoadBtn">Confirm before you edit</label>
|
|
<button type="button" class="btn btn-quiet" id="adLoadBtn">Load this record</button>
|
|
</div>
|
|
<p class="adm-loaded" id="adLoadInfo" role="status">Enter a record id and load it — you will see who it is before anything is written.</p>
|
|
<hr class="adm-sep" />
|
|
|
|
<div class="form-field">
|
|
<label for="adName">Name</label>
|
|
<input id="adName" name="Name" type="text" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adEmail">Email</label>
|
|
<input id="adEmail" name="email" type="email" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adClients">Projects on ODOO4projects</label>
|
|
<input id="adClients" name="odoo4projects_clients" type="number" min="0" />
|
|
<span class="hint">A number we record ourselves — not the freelancer's, not a rating.</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adFocus">Focus <span class="hint">(one line, at most 70 characters)</span></label>
|
|
<input id="adFocus" name="Focus" type="text" maxlength="70" aria-describedby="adFocusCount" />
|
|
<span class="focus-counter" id="adFocusCount" aria-live="polite">0 / 70</span>
|
|
</div>
|
|
<div class="form-field full">
|
|
<label for="adDescription">Description <span class="hint">(at most 230 characters)</span></label>
|
|
<textarea id="adDescription" name="description" rows="4" maxlength="230" aria-describedby="adDescriptionCount"></textarea>
|
|
<span class="focus-counter" id="adDescriptionCount" aria-live="polite">0 / 230</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adCountry">Country</label>
|
|
<select id="adCountry" name="Country" required>
|
|
<!-- COUNTRY-OPTIONS-START -->
|
|
<option value="">— choose —</option>
|
|
<option value="AD">Andorra</option>
|
|
<option value="AE">United Arab Emirates</option>
|
|
<option value="AF">Afghanistan</option>
|
|
<option value="AG">Antigua and Barbuda</option>
|
|
<option value="AI">Anguilla</option>
|
|
<option value="AL">Albania</option>
|
|
<option value="AM">Armenia</option>
|
|
<option value="AO">Angola</option>
|
|
<option value="AQ">Antarctica</option>
|
|
<option value="AR">Argentina</option>
|
|
<option value="AS">American Samoa</option>
|
|
<option value="AT">Austria</option>
|
|
<option value="AU">Australia</option>
|
|
<option value="AW">Aruba</option>
|
|
<option value="AX">Aland</option>
|
|
<option value="AZ">Azerbaijan</option>
|
|
<option value="BA">Bosnia and Herzegovina</option>
|
|
<option value="BB">Barbados</option>
|
|
<option value="BD">Bangladesh</option>
|
|
<option value="BE">Belgium</option>
|
|
<option value="BF">Burkina Faso</option>
|
|
<option value="BG">Bulgaria</option>
|
|
<option value="BH">Bahrain</option>
|
|
<option value="BI">Burundi</option>
|
|
<option value="BJ">Benin</option>
|
|
<option value="BL">Saint Barthelemy</option>
|
|
<option value="BM">Bermuda</option>
|
|
<option value="BN">Brunei</option>
|
|
<option value="BO">Bolivia</option>
|
|
<option value="BQ">Bonaire</option>
|
|
<option value="BR">Brazil</option>
|
|
<option value="BS">Bahamas</option>
|
|
<option value="BT">Bhutan</option>
|
|
<option value="BV">Bouvet Island</option>
|
|
<option value="BW">Botswana</option>
|
|
<option value="BY">Belarus</option>
|
|
<option value="BZ">Belize</option>
|
|
<option value="CA">Canada</option>
|
|
<option value="CC">Cocos (Keeling) Islands</option>
|
|
<option value="CD">Democratic Republic of the Congo</option>
|
|
<option value="CF">Central African Republic</option>
|
|
<option value="CG">Republic of the Congo</option>
|
|
<option value="CH">Switzerland</option>
|
|
<option value="CI">Ivory Coast</option>
|
|
<option value="CK">Cook Islands</option>
|
|
<option value="CL">Chile</option>
|
|
<option value="CM">Cameroon</option>
|
|
<option value="CN">China</option>
|
|
<option value="CO">Colombia</option>
|
|
<option value="CR">Costa Rica</option>
|
|
<option value="CU">Cuba</option>
|
|
<option value="CV">Cape Verde</option>
|
|
<option value="CW">Curacao</option>
|
|
<option value="CX">Christmas Island</option>
|
|
<option value="CY">Cyprus</option>
|
|
<option value="CZ">Czech Republic</option>
|
|
<option value="DE">Germany</option>
|
|
<option value="DJ">Djibouti</option>
|
|
<option value="DK">Denmark</option>
|
|
<option value="DM">Dominica</option>
|
|
<option value="DO">Dominican Republic</option>
|
|
<option value="DZ">Algeria</option>
|
|
<option value="EC">Ecuador</option>
|
|
<option value="EE">Estonia</option>
|
|
<option value="EG">Egypt</option>
|
|
<option value="EH">Western Sahara</option>
|
|
<option value="ER">Eritrea</option>
|
|
<option value="ES">Spain</option>
|
|
<option value="ET">Ethiopia</option>
|
|
<option value="FI">Finland</option>
|
|
<option value="FJ">Fiji</option>
|
|
<option value="FK">Falkland Islands</option>
|
|
<option value="FM">Micronesia</option>
|
|
<option value="FO">Faroe Islands</option>
|
|
<option value="FR">France</option>
|
|
<option value="GA">Gabon</option>
|
|
<option value="GB">United Kingdom</option>
|
|
<option value="GD">Grenada</option>
|
|
<option value="GE">Georgia</option>
|
|
<option value="GF">French Guiana</option>
|
|
<option value="GG">Guernsey</option>
|
|
<option value="GH">Ghana</option>
|
|
<option value="GI">Gibraltar</option>
|
|
<option value="GL">Greenland</option>
|
|
<option value="GM">Gambia</option>
|
|
<option value="GN">Guinea</option>
|
|
<option value="GP">Guadeloupe</option>
|
|
<option value="GQ">Equatorial Guinea</option>
|
|
<option value="GR">Greece</option>
|
|
<option value="GS">South Georgia and the South Sandwich Islands</option>
|
|
<option value="GT">Guatemala</option>
|
|
<option value="GU">Guam</option>
|
|
<option value="GW">Guinea-Bissau</option>
|
|
<option value="GY">Guyana</option>
|
|
<option value="HK">Hong Kong</option>
|
|
<option value="HM">Heard Island and McDonald Islands</option>
|
|
<option value="HN">Honduras</option>
|
|
<option value="HR">Croatia</option>
|
|
<option value="HT">Haiti</option>
|
|
<option value="HU">Hungary</option>
|
|
<option value="ID">Indonesia</option>
|
|
<option value="IE">Ireland</option>
|
|
<option value="IL">Israel</option>
|
|
<option value="IM">Isle of Man</option>
|
|
<option value="IN">India</option>
|
|
<option value="IO">British Indian Ocean Territory</option>
|
|
<option value="IQ">Iraq</option>
|
|
<option value="IR">Iran</option>
|
|
<option value="IS">Iceland</option>
|
|
<option value="IT">Italy</option>
|
|
<option value="JE">Jersey</option>
|
|
<option value="JM">Jamaica</option>
|
|
<option value="JO">Jordan</option>
|
|
<option value="JP">Japan</option>
|
|
<option value="KE">Kenya</option>
|
|
<option value="KG">Kyrgyzstan</option>
|
|
<option value="KH">Cambodia</option>
|
|
<option value="KI">Kiribati</option>
|
|
<option value="KM">Comoros</option>
|
|
<option value="KN">Saint Kitts and Nevis</option>
|
|
<option value="KP">North Korea</option>
|
|
<option value="KR">South Korea</option>
|
|
<option value="KW">Kuwait</option>
|
|
<option value="KY">Cayman Islands</option>
|
|
<option value="KZ">Kazakhstan</option>
|
|
<option value="LA">Laos</option>
|
|
<option value="LB">Lebanon</option>
|
|
<option value="LC">Saint Lucia</option>
|
|
<option value="LI">Liechtenstein</option>
|
|
<option value="LK">Sri Lanka</option>
|
|
<option value="LR">Liberia</option>
|
|
<option value="LS">Lesotho</option>
|
|
<option value="LT">Lithuania</option>
|
|
<option value="LU">Luxembourg</option>
|
|
<option value="LV">Latvia</option>
|
|
<option value="LY">Libya</option>
|
|
<option value="MA">Morocco</option>
|
|
<option value="MC">Monaco</option>
|
|
<option value="MD">Moldova</option>
|
|
<option value="ME">Montenegro</option>
|
|
<option value="MF">Saint Martin</option>
|
|
<option value="MG">Madagascar</option>
|
|
<option value="MH">Marshall Islands</option>
|
|
<option value="MK">North Macedonia</option>
|
|
<option value="ML">Mali</option>
|
|
<option value="MM">Myanmar (Burma)</option>
|
|
<option value="MN">Mongolia</option>
|
|
<option value="MO">Macao</option>
|
|
<option value="MP">Northern Mariana Islands</option>
|
|
<option value="MQ">Martinique</option>
|
|
<option value="MR">Mauritania</option>
|
|
<option value="MS">Montserrat</option>
|
|
<option value="MT">Malta</option>
|
|
<option value="MU">Mauritius</option>
|
|
<option value="MV">Maldives</option>
|
|
<option value="MW">Malawi</option>
|
|
<option value="MX">Mexico</option>
|
|
<option value="MY">Malaysia</option>
|
|
<option value="MZ">Mozambique</option>
|
|
<option value="NA">Namibia</option>
|
|
<option value="NC">New Caledonia</option>
|
|
<option value="NE">Niger</option>
|
|
<option value="NF">Norfolk Island</option>
|
|
<option value="NG">Nigeria</option>
|
|
<option value="NI">Nicaragua</option>
|
|
<option value="NL">Netherlands</option>
|
|
<option value="NO">Norway</option>
|
|
<option value="NP">Nepal</option>
|
|
<option value="NR">Nauru</option>
|
|
<option value="NU">Niue</option>
|
|
<option value="NZ">New Zealand</option>
|
|
<option value="OM">Oman</option>
|
|
<option value="PA">Panama</option>
|
|
<option value="PE">Peru</option>
|
|
<option value="PF">French Polynesia</option>
|
|
<option value="PG">Papua New Guinea</option>
|
|
<option value="PH">Philippines</option>
|
|
<option value="PK">Pakistan</option>
|
|
<option value="PL">Poland</option>
|
|
<option value="PM">Saint Pierre and Miquelon</option>
|
|
<option value="PN">Pitcairn Islands</option>
|
|
<option value="PR">Puerto Rico</option>
|
|
<option value="PS">Palestine</option>
|
|
<option value="PT">Portugal</option>
|
|
<option value="PW">Palau</option>
|
|
<option value="PY">Paraguay</option>
|
|
<option value="QA">Qatar</option>
|
|
<option value="RE">Reunion</option>
|
|
<option value="RO">Romania</option>
|
|
<option value="RS">Serbia</option>
|
|
<option value="RU">Russia</option>
|
|
<option value="RW">Rwanda</option>
|
|
<option value="SA">Saudi Arabia</option>
|
|
<option value="SB">Solomon Islands</option>
|
|
<option value="SC">Seychelles</option>
|
|
<option value="SD">Sudan</option>
|
|
<option value="SE">Sweden</option>
|
|
<option value="SG">Singapore</option>
|
|
<option value="SH">Saint Helena</option>
|
|
<option value="SI">Slovenia</option>
|
|
<option value="SJ">Svalbard and Jan Mayen</option>
|
|
<option value="SK">Slovakia</option>
|
|
<option value="SL">Sierra Leone</option>
|
|
<option value="SM">San Marino</option>
|
|
<option value="SN">Senegal</option>
|
|
<option value="SO">Somalia</option>
|
|
<option value="SR">Suriname</option>
|
|
<option value="SS">South Sudan</option>
|
|
<option value="ST">Sao Tome and Principe</option>
|
|
<option value="SV">El Salvador</option>
|
|
<option value="SX">Sint Maarten</option>
|
|
<option value="SY">Syria</option>
|
|
<option value="SZ">Eswatini</option>
|
|
<option value="TC">Turks and Caicos Islands</option>
|
|
<option value="TD">Chad</option>
|
|
<option value="TF">French Southern Territories</option>
|
|
<option value="TG">Togo</option>
|
|
<option value="TH">Thailand</option>
|
|
<option value="TJ">Tajikistan</option>
|
|
<option value="TK">Tokelau</option>
|
|
<option value="TL">East Timor</option>
|
|
<option value="TM">Turkmenistan</option>
|
|
<option value="TN">Tunisia</option>
|
|
<option value="TO">Tonga</option>
|
|
<option value="TR">Turkey</option>
|
|
<option value="TT">Trinidad and Tobago</option>
|
|
<option value="TV">Tuvalu</option>
|
|
<option value="TW">Taiwan</option>
|
|
<option value="TZ">Tanzania</option>
|
|
<option value="UA">Ukraine</option>
|
|
<option value="UG">Uganda</option>
|
|
<option value="UM">U.S. Minor Outlying Islands</option>
|
|
<option value="US">United States</option>
|
|
<option value="UY">Uruguay</option>
|
|
<option value="UZ">Uzbekistan</option>
|
|
<option value="VA">Vatican City</option>
|
|
<option value="VC">Saint Vincent and the Grenadines</option>
|
|
<option value="VE">Venezuela</option>
|
|
<option value="VG">British Virgin Islands</option>
|
|
<option value="VI">U.S. Virgin Islands</option>
|
|
<option value="VN">Vietnam</option>
|
|
<option value="VU">Vanuatu</option>
|
|
<option value="WF">Wallis and Futuna</option>
|
|
<option value="WS">Samoa</option>
|
|
<option value="YE">Yemen</option>
|
|
<option value="YT">Mayotte</option>
|
|
<option value="ZA">South Africa</option>
|
|
<option value="ZM">Zambia</option>
|
|
<option value="ZW">Zimbabwe</option>
|
|
<!-- COUNTRY-OPTIONS-END -->
|
|
</select>
|
|
<span class="hint">Stored as the ISO code (e.g. DE).</span>
|
|
</div>
|
|
<fieldset class="form-field full focus-field">
|
|
<legend>Skill profile — five numbers, 0 to 5, totalling 15</legend>
|
|
<div class="focus-axis">
|
|
<label for="ad-axis-functional">Functional</label>
|
|
<input type="range" id="ad-axis-functional" name="functional" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-axis-functional">0</output>
|
|
</div>
|
|
<div class="focus-axis">
|
|
<label for="ad-axis-system">System</label>
|
|
<input type="range" id="ad-axis-system" name="system" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-axis-system">0</output>
|
|
</div>
|
|
<div class="focus-axis">
|
|
<label for="ad-axis-modules">Modules</label>
|
|
<input type="range" id="ad-axis-modules" name="modules" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-axis-modules">0</output>
|
|
</div>
|
|
<div class="focus-axis">
|
|
<label for="ad-axis-integration">Integration</label>
|
|
<input type="range" id="ad-axis-integration" name="integration" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-axis-integration">0</output>
|
|
</div>
|
|
<div class="focus-axis">
|
|
<label for="ad-axis-ai">AI</label>
|
|
<input type="range" id="ad-axis-ai" name="ai" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-axis-ai">0</output>
|
|
</div>
|
|
<p class="focus-total" id="adFocusTotal" aria-live="polite">0 of 15 points</p>
|
|
<p class="focus-warn" id="adFocusWarn" role="alert"></p>
|
|
<p class="focus-scale">How much of the freelancer's work each area is — 0 to 5, totalling 15. Leave all five at 0 to leave the profile untouched (a save then keeps the record's current numbers); any other value must total exactly 15.</p>
|
|
</fieldset>
|
|
<fieldset class="form-field full lang-field">
|
|
<legend>Languages — 0 to 5, same scale as the public form</legend>
|
|
<div class="focus-axis">
|
|
<label for="ad-lang-english">English</label>
|
|
<input type="range" id="ad-lang-english" name="english" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-lang-english">0</output>
|
|
</div>
|
|
<div class="focus-axis">
|
|
<label for="ad-lang-german">German</label>
|
|
<input type="range" id="ad-lang-german" name="german" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-lang-german">0</output>
|
|
</div>
|
|
<div class="focus-axis">
|
|
<label for="ad-lang-spanish">Spanish</label>
|
|
<input type="range" id="ad-lang-spanish" name="spanish" min="0" max="5" step="1" value="0" />
|
|
<output for="ad-lang-spanish">0</output>
|
|
</div>
|
|
<p class="focus-scale">0 means “cannot speak it”; 5 means “native”. The applicant's own assessment.</p>
|
|
</fieldset>
|
|
<div class="form-field">
|
|
<label for="adGithub">GitHub URL</label>
|
|
<input id="adGithub" name="github" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adOca">OCA profile URL</label>
|
|
<input id="adOca" name="OCA_link" type="text" inputmode="url" />
|
|
<span class="hint">The OCA member profile link. A chip leads to it from the card when the host is apps.odoo-community.org and the path is /community/…</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adLinkedin">LinkedIn URL</label>
|
|
<input id="adLinkedin" name="Linkedin" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adUpwork">Upwork URL</label>
|
|
<input id="adUpwork" name="upwork" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adFiverr">Fiverr URL</label>
|
|
<input id="adFiverr" name="fiverr" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adImage">Image URL</label>
|
|
<input id="adImage" name="Image" type="text" inputmode="url" />
|
|
<span class="hint">A link (https://…). A photo shows on the card only when this is a link — an inline/base64 image is not displayed.</span>
|
|
</div>
|
|
<label class="form-check">
|
|
<input type="checkbox" name="is_individual" value="1" />
|
|
<span>Works as an individual.</span>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-brass">Save this record</button>
|
|
<span class="form-status" id="adminStatus" role="status"></span>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
/* ============================================================================
|
|
Freelancer records — admin page script.
|
|
|
|
ONE webhook, for everything. The freelancer platform is a SEPARATE system from
|
|
the CRM: there is no /crm, no /crm_odoo and nothing from the CRM skill here.
|
|
|
|
Write contract (same URL, id decides):
|
|
POST with an id → UPDATE that record
|
|
POST without an id → CREATE a new record
|
|
Email confirmation applies to both paths.
|
|
|
|
READ-MODIFY-WRITE (the reason this page loads the record first): a value the
|
|
owner did not touch must keep the record's CURRENT value. Loading the record
|
|
syncs every control to it, so an untouched control sends what the record already
|
|
holds. An edit is refused until the record has been loaded and confirmed.
|
|
|
|
The focus block: left all-zero it is simply NOT sent (an unrelated correction
|
|
must stay savable); any other value must total exactly 15.
|
|
============================================================================ */
|
|
var ODOO_EXPERTS_WEBHOOK =
|
|
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
|
|
|
|
var FOCUS_AXES = ["functional", "system", "modules", "integration", "ai"];
|
|
var FOCUS_TOTAL = 15;
|
|
var FOCUS_MAX = 5;
|
|
var FOCUS_MAX_CHARS = 70;
|
|
// The description is capped at 230 characters, same as the public application form.
|
|
var DESC_MAX_CHARS = 230;
|
|
var LANG_FIELDS = [["english", "English"], ["german", "German"], ["spanish", "Spanish"]];
|
|
// Country VALUE FORMAT — what the Country select sends to the webhook.
|
|
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default)
|
|
// "name" → the full English name, e.g. "Germany"
|
|
var COUNTRY_VALUE_FORMAT = "code";
|
|
var BOOL_FIELDS = ["is_individual"];
|
|
var NUM_FIELDS = ["odoo4projects_clients"].concat(FOCUS_AXES).concat(
|
|
LANG_FIELDS.map(function (p) { return p[0]; })
|
|
);
|
|
var TEXT_FIELDS = [
|
|
"Name", "email", "Focus", "description", "Country",
|
|
"Linkedin", "upwork", "fiverr", "github", "OCA_link", "Image"
|
|
];
|
|
|
|
var loadedRecord = null;
|
|
|
|
function fieldElements(form, name) {
|
|
return Array.prototype.slice.call(
|
|
form.querySelectorAll('[name="' + name.replace(/"/g, '\\"') + '"]')
|
|
);
|
|
}
|
|
|
|
function fold(text) {
|
|
return String(text == null ? "" : text)
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function intOr(value, fallback) {
|
|
var n = parseInt(value, 10);
|
|
return isNaN(n) ? fallback : n;
|
|
}
|
|
|
|
function clamp05(n) { return Math.max(0, Math.min(FOCUS_MAX, n)); }
|
|
|
|
function countryValue(form) {
|
|
var sel = form.querySelector('select[name="Country"]');
|
|
if (!sel || !sel.value) return "";
|
|
if (COUNTRY_VALUE_FORMAT === "name") {
|
|
var opt = sel.options[sel.selectedIndex];
|
|
return opt ? opt.textContent.trim() : "";
|
|
}
|
|
return sel.value.trim();
|
|
}
|
|
|
|
function setStatus(el, message, state) {
|
|
if (!el) return;
|
|
el.textContent = message;
|
|
if (state) el.setAttribute("data-state", state);
|
|
else el.removeAttribute("data-state");
|
|
}
|
|
|
|
function recordField(rec, name) {
|
|
if (rec[name] !== undefined) return rec[name];
|
|
if (name === "Country" && rec.country !== undefined) return rec.country;
|
|
return undefined;
|
|
}
|
|
|
|
function focusSum(form) {
|
|
return FOCUS_AXES.reduce(function (s, axis) {
|
|
var el = form.querySelector('[name="' + axis + '"]');
|
|
return s + (el ? intOr(el.value, 0) : 0);
|
|
}, 0);
|
|
}
|
|
|
|
function focusError(form, required) {
|
|
var sum = focusSum(form);
|
|
if (sum === 0) {
|
|
return required ? "Distribute all 15 points across the five areas." : "";
|
|
}
|
|
if (sum !== FOCUS_TOTAL) {
|
|
return "The five focus numbers total " + sum + ". They must total exactly " +
|
|
FOCUS_TOTAL + " (or all be 0 to leave the profile untouched).";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function refreshFocus(form) {
|
|
var sum = 0;
|
|
FOCUS_AXES.forEach(function (axis) {
|
|
var el = form.querySelector('[name="' + axis + '"]');
|
|
if (!el) return;
|
|
var n = clamp05(intOr(el.value, 0));
|
|
el.value = String(n);
|
|
sum += n;
|
|
var out = el.parentNode ? el.parentNode.querySelector("output") : null;
|
|
if (out) out.textContent = String(n);
|
|
el.setAttribute("aria-valuetext", n + " of " + FOCUS_MAX);
|
|
});
|
|
var langs = form.querySelectorAll(".lang-field input[type=range]");
|
|
Array.prototype.forEach.call(langs, function (el) {
|
|
var n = clamp05(intOr(el.value, 0));
|
|
el.value = String(n);
|
|
var out = el.parentNode ? el.parentNode.querySelector("output") : null;
|
|
if (out) out.textContent = String(n);
|
|
el.setAttribute("aria-valuetext", n + " of " + FOCUS_MAX);
|
|
});
|
|
var total = document.getElementById("adFocusTotal");
|
|
if (total) total.textContent = sum + " of " + FOCUS_TOTAL + " points";
|
|
var warn = document.getElementById("adFocusWarn");
|
|
if (warn) {
|
|
warn.textContent = (sum === 0 || sum === FOCUS_TOTAL)
|
|
? ""
|
|
: "You have " + sum + " of 15. The five must total exactly 15, or all be 0 to leave the profile untouched.";
|
|
}
|
|
}
|
|
|
|
/* Copy a loaded record into the form, so an untouched field carries the record's
|
|
current value through to the write. */
|
|
function populate(rec) {
|
|
var form = document.getElementById("adminForm");
|
|
TEXT_FIELDS.forEach(function (name) {
|
|
var el = form.querySelector('[name="' + name + '"]');
|
|
if (!el) return;
|
|
var v = recordField(rec, name);
|
|
el.value = v == null ? "" : String(v);
|
|
});
|
|
var clients = form.querySelector('[name="odoo4projects_clients"]');
|
|
if (clients) {
|
|
var n = rec.odoo4projects_clients;
|
|
clients.value = n == null ? "" : String(n);
|
|
}
|
|
BOOL_FIELDS.forEach(function (name) {
|
|
var box = form.querySelector('[name="' + name + '"]');
|
|
if (!box) return;
|
|
var val = rec[name] === true;
|
|
box.checked = val;
|
|
box.dataset.loaded = val ? "true" : "false";
|
|
});
|
|
// The five focus axes and the three languages — plain numbers, read off the
|
|
// record. A field the record does not carry stays 0 (and the block is left
|
|
// unsent unless it totals 15).
|
|
FOCUS_AXES.forEach(function (axis) {
|
|
var el = form.querySelector('[name="' + axis + '"]');
|
|
if (el) el.value = String(clamp05(intOr(rec[axis], 0)));
|
|
});
|
|
LANG_FIELDS.forEach(function (p) {
|
|
var el = form.querySelector('[name="' + p[0] + '"]');
|
|
if (el) el.value = String(clamp05(intOr(rec[p[0]], 0)));
|
|
});
|
|
refreshFocus(form);
|
|
var focusInput = document.getElementById("adFocus");
|
|
var focusCount = document.getElementById("adFocusCount");
|
|
if (focusInput && focusCount) {
|
|
focusCount.textContent = focusInput.value.length + " / " + FOCUS_MAX_CHARS;
|
|
}
|
|
var descInput = document.getElementById("adDescription");
|
|
var descCount = document.getElementById("adDescriptionCount");
|
|
if (descInput && descCount) {
|
|
descCount.textContent = descInput.value.length + " / " + DESC_MAX_CHARS;
|
|
}
|
|
}
|
|
|
|
function loadRecord() {
|
|
var form = document.getElementById("adminForm");
|
|
var info = document.getElementById("adLoadInfo");
|
|
var idRaw = form.querySelector('[name="id"]').value.trim();
|
|
loadedRecord = null;
|
|
if (!idRaw) {
|
|
setStatus(info, "Enter a record id first — or leave it blank to create a new record.", "err");
|
|
return;
|
|
}
|
|
setStatus(info, "Loading record " + idRaw + "…", null);
|
|
fetch(ODOO_EXPERTS_WEBHOOK, { method: "GET" })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (payload) {
|
|
var list = Array.isArray(payload) ? payload : (payload && payload.data) || [];
|
|
var rec = null;
|
|
list.forEach(function (r) {
|
|
if (String(r.id) === String(idRaw)) rec = r;
|
|
});
|
|
if (!rec) {
|
|
setStatus(info, "No record with id " + idRaw + ". Nothing was loaded.", "err");
|
|
return;
|
|
}
|
|
loadedRecord = rec;
|
|
populate(rec);
|
|
var who = rec.Name ? rec.Name : "(no name)";
|
|
setStatus(
|
|
info,
|
|
"Loaded: " + who + " — id " + rec.id + ". Check this is the right person, then edit and save. Fields you leave alone keep their current value.",
|
|
"ok"
|
|
);
|
|
})
|
|
.catch(function () {
|
|
setStatus(info, "Could not read the record list. Try again.", "err");
|
|
});
|
|
}
|
|
|
|
function buildPayload(form, withId) {
|
|
var payload = {};
|
|
var names = [];
|
|
Array.prototype.forEach.call(form.querySelectorAll("[name]"), function (el) {
|
|
if (names.indexOf(el.name) === -1) names.push(el.name);
|
|
});
|
|
|
|
names.forEach(function (name) {
|
|
var els = fieldElements(form, name);
|
|
if (!els.length) return;
|
|
|
|
if (name === "id") {
|
|
if (!withId) return;
|
|
var raw = els[0].value.trim();
|
|
if (raw) payload.id = Number(raw);
|
|
return;
|
|
}
|
|
|
|
if (name === "Country") {
|
|
var countryVal = countryValue(form);
|
|
if (countryVal !== "") payload[name] = countryVal;
|
|
return;
|
|
}
|
|
|
|
if (BOOL_FIELDS.indexOf(name) !== -1) {
|
|
var box = els[0];
|
|
if (withId && box.dataset.loaded !== undefined) {
|
|
var original = box.dataset.loaded === "true";
|
|
var now = !!box.checked;
|
|
payload[name] = now === original ? original : now;
|
|
} else {
|
|
payload[name] = !!box.checked;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (NUM_FIELDS.indexOf(name) !== -1) {
|
|
// A focus block that is not a valid 15 is left unsent, so an update cannot
|
|
// wipe it (the gate already refused a half-set block).
|
|
if (FOCUS_AXES.indexOf(name) !== -1 && focusSum(form) !== FOCUS_TOTAL) return;
|
|
var nv = els[0].value.trim();
|
|
if (nv !== "") payload[name] = Number(nv);
|
|
return;
|
|
}
|
|
|
|
var value = els[0].value.trim();
|
|
if (value !== "") payload[name] = value;
|
|
});
|
|
|
|
return payload;
|
|
}
|
|
|
|
function postToWebhook(payload) {
|
|
return fetch(ODOO_EXPERTS_WEBHOOK, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
|
|
(function init() {
|
|
var form = document.getElementById("adminForm");
|
|
if (!form) return;
|
|
var info = document.getElementById("adLoadInfo");
|
|
var status = document.getElementById("adminStatus");
|
|
|
|
document.getElementById("adLoadBtn").addEventListener("click", loadRecord);
|
|
|
|
form.querySelector('[name="id"]').addEventListener("input", function () {
|
|
if (loadedRecord && String(loadedRecord.id) !== this.value.trim()) {
|
|
loadedRecord = null;
|
|
setStatus(info, "The id changed — load the record again to confirm who you are editing.", "err");
|
|
}
|
|
});
|
|
|
|
Array.prototype.forEach.call(form.querySelectorAll(".focus-field input, .lang-field input"),
|
|
function (el) { el.addEventListener("input", function () { refreshFocus(form); }); });
|
|
|
|
var focusInput = document.getElementById("adFocus");
|
|
var focusCount = document.getElementById("adFocusCount");
|
|
if (focusInput && focusCount) {
|
|
var refreshCount = function () {
|
|
var n = focusInput.value.length;
|
|
focusCount.textContent = n + " / " + FOCUS_MAX_CHARS;
|
|
focusCount.setAttribute("data-over", n > FOCUS_MAX_CHARS ? "1" : "0");
|
|
};
|
|
focusInput.addEventListener("input", refreshCount);
|
|
refreshCount();
|
|
}
|
|
var descInput = document.getElementById("adDescription");
|
|
var descCount = document.getElementById("adDescriptionCount");
|
|
if (descInput && descCount) {
|
|
var refreshDescCount = function () {
|
|
var n = descInput.value.length;
|
|
descCount.textContent = n + " / " + DESC_MAX_CHARS;
|
|
descCount.setAttribute("data-over", n > DESC_MAX_CHARS ? "1" : "0");
|
|
};
|
|
descInput.addEventListener("input", refreshDescCount);
|
|
refreshDescCount();
|
|
}
|
|
refreshFocus(form);
|
|
|
|
form.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
if (!form.reportValidity()) return;
|
|
var idRaw = form.querySelector('[name="id"]').value.trim();
|
|
var hasId = !!idRaw;
|
|
|
|
if (hasId && (!loadedRecord || String(loadedRecord.id) !== idRaw)) {
|
|
setStatus(status,
|
|
"Load the record first (Load this record) and confirm it is the right person. Nothing was saved.",
|
|
"err");
|
|
return;
|
|
}
|
|
|
|
var focusMsg = focusError(form, false);
|
|
if (focusMsg) {
|
|
setStatus(status, focusMsg, "err");
|
|
var warn = document.getElementById("adFocusWarn");
|
|
if (warn) warn.textContent = focusMsg;
|
|
return;
|
|
}
|
|
|
|
var submit = form.querySelector('button[type="submit"]');
|
|
submit.disabled = true;
|
|
setStatus(status, "Saving…", null);
|
|
|
|
postToWebhook(buildPayload(form, true))
|
|
.then(function () {
|
|
setStatus(status,
|
|
hasId
|
|
? "Saved. Check your inbox to confirm the change before it is applied."
|
|
: "New record sent. Check your inbox to confirm it before it goes live.",
|
|
"ok");
|
|
})
|
|
.catch(function () {
|
|
setStatus(status, "That did not save. Please try again.", "err");
|
|
})
|
|
.then(function () { submit.disabled = false; });
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|