Selaa lähdekoodia

add customer API endpoints and customer_lookup job attribute

- Add GET /api/customers, /api/customers/{id}, /api/customer-types endpoints
- Add allByTypeWithType() and include api_match_field in customer queries
- Add customer_lookup attribute type to job types: stores customer_type_id
  and imports the customer type's attributes as real fields at design time
- Job form: customer_lookup renders a searchable dropdown that fetches
  /api/customers?customer_type_id=N and auto-fills all matching attribute
  values when a customer is selected
pull/3/head
Daniel Covington 2 viikkoa sitten
vanhempi
commit
3ded5fec71
6 muutettua tiedostoa jossa 215 lisäystä ja 41 poistoa
  1. +19
    -13
      app/Controllers/JobTypeController.php
  2. +4
    -2
      app/Views/job-types/create.php
  3. +4
    -2
      app/Views/job-types/edit.php
  4. +40
    -2
      app/Views/jobs/create.php
  5. +40
    -2
      app/Views/jobs/edit.php
  6. +108
    -20
      public/js/app.js

+ 19
- 13
app/Controllers/JobTypeController.php Näytä tiedosto

@@ -223,12 +223,13 @@ class JobTypeController extends Controller
->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.')
->errors());

$attributeAliases = (array) ($request->input('attribute_alias') ?? []);
$attributeApiUrls = (array) ($request->input('attribute_api_url') ?? []);
$attributeApiFormats = (array) ($request->input('attribute_api_format') ?? []);
$attributeApiReturnTypes = (array) ($request->input('attribute_api_return_type') ?? []);
$attributeApiMatchFields = (array) ($request->input('attribute_api_match_field') ?? []);
$attributeApiAutoFills = (array) ($request->input('attribute_api_auto_fill') ?? []);
$attributeAliases = (array) ($request->input('attribute_alias') ?? []);
$attributeApiUrls = (array) ($request->input('attribute_api_url') ?? []);
$attributeApiFormats = (array) ($request->input('attribute_api_format') ?? []);
$attributeApiReturnTypes = (array) ($request->input('attribute_api_return_type') ?? []);
$attributeApiMatchFields = (array) ($request->input('attribute_api_match_field') ?? []);
$attributeApiAutoFills = (array) ($request->input('attribute_api_auto_fill') ?? []);
$attributeCustomerTypeIds = (array) ($request->input('attribute_customer_type_id') ?? []);

$attributes = [];

@@ -237,11 +238,10 @@ class JobTypeController extends Controller
$attrType = trim((string) ($attributeTypes[$i] ?? 'text'));
if ($attrName === '') continue;

// 'customer' is a design-time placeholder that is expanded into real attribute rows
// by the Job Type editor JS before submit. If one slips through, drop it.
// 'customer' is a legacy design-time placeholder (now replaced by customer_lookup). Drop if it slips through.
if ($attrType === 'customer') continue;

$validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text';
$validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup', 'customer_lookup'], true) ? $attrType : 'text';

$attr = [
'name' => $attrName,
@@ -262,6 +262,11 @@ class JobTypeController extends Controller
$attr['api_auto_fill'] = trim((string) ($attributeApiAutoFills[$i] ?? ''));
}

if ($validatedType === 'customer_lookup') {
$attr['customer_type_id'] = (int) ($attributeCustomerTypeIds[$i] ?? 0);
$attr['api_match_field'] = trim((string) ($attributeApiMatchFields[$i] ?? ''));
}

$attributes[] = $attr;
}

@@ -289,14 +294,15 @@ class JobTypeController extends Controller
return new CustomerTypeRepository(database());
}

/** @return list<array{id: int, name: string, attributes: array}> */
/** @return list<array{id: int, name: string, api_match_field: string, attributes: array}> */
private function loadCustomerTypes(): array
{
return array_map(static function (array $t): array {
return [
'id' => (int) $t['id'],
'name' => (string) $t['name'],
'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [],
'id' => (int) $t['id'],
'name' => (string) $t['name'],
'api_match_field' => (string) ($t['api_match_field'] ?? ''),
'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [],
];
}, $this->customerTypeRepo()->allOrderedByName());
}


+ 4
- 2
app/Views/job-types/create.php Näytä tiedosto

@@ -74,7 +74,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T
<option value="date">Date</option>
<option value="boolean">True/False</option>
<option value="api_lookup">API Lookup</option>
<option value="customer">Customer Type</option>
<option value="customer_lookup">Customer Lookup</option>
</select>
</label>
<div class="attribute-remove">
@@ -121,7 +121,9 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T
</select>
</label>
</div>
<div class="customer-type-config" x-show="attr.type === 'customer'">
<div class="customer-type-config" x-show="attr.type === 'customer_lookup'">
<input type="hidden" :name="`attribute_customer_type_id[${index}]`" :value="attr.customer_type_id || 0">
<input type="hidden" :name="`attribute_api_match_field[${index}]`" :value="attr.api_match_field || ''">
<label class="field">
<span>Customer Type</span>
<select class="input"


+ 4
- 2
app/Views/job-types/edit.php Näytä tiedosto

@@ -81,7 +81,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T
<option value="date">Date</option>
<option value="boolean">True/False</option>
<option value="api_lookup">API Lookup</option>
<option value="customer">Customer Type</option>
<option value="customer_lookup">Customer Lookup</option>
</select>
</label>
<div class="attribute-remove">
@@ -128,7 +128,9 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T
</select>
</label>
</div>
<div class="customer-type-config" x-show="attr.type === 'customer'">
<div class="customer-type-config" x-show="attr.type === 'customer_lookup'">
<input type="hidden" :name="`attribute_customer_type_id[${index}]`" :value="attr.customer_type_id || 0">
<input type="hidden" :name="`attribute_api_match_field[${index}]`" :value="attr.api_match_field || ''">
<label class="field">
<span>Customer Type</span>
<select class="input"


+ 40
- 2
app/Views/jobs/create.php Näytä tiedosto

@@ -76,7 +76,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
</div>
<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }">
<label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' || attr.type === 'customer_lookup' }">
<span x-text="attr.name"></span>
<template x-if="attr.type === 'boolean'">
<select class="input"
@@ -126,7 +126,45 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
</div>
</div>
</template>
<template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'">
<template x-if="attr.type === 'customer_lookup'">
<div class="api-lookup-field">
<input class="input" type="text"
:name="`attribute_values[${attr.name}]`"
x-model="attributeValues[attr.name]"
placeholder="Search customers…"
autocomplete="off"
x-on:focus="openCustomerLookup(attr.name)"
x-on:blur="closeCustomerLookup(attr.name)"
x-on:keydown.escape.prevent="closeCustomerLookup(attr.name)">
<div class="api-lookup-dropdown"
x-show="customerLookupOpen[attr.name]"
x-on:mousedown.prevent>
<p class="api-lookup-loading" x-show="customerLookupState[attr.name] === 'loading'">Loading…</p>
<div class="api-lookup-table" x-show="customerLookupState[attr.name] !== 'loading'">
<div class="api-lookup-thead"
:style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`">
<template x-for="f in getCustomerOptions(attr.name).fields" :key="f">
<span x-text="f"></span>
</template>
</div>
<div class="api-lookup-tbody">
<template x-for="rec in getFilteredCustomers(attr.name, attributeValues[attr.name])" :key="rec._primary">
<div class="api-lookup-tr"
:class="{ 'is-selected': attributeValues[attr.name] === rec._primary }"
:style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`"
x-on:click="selectCustomer(attr, rec)">
<template x-for="(v, i) in rec._display" :key="i">
<span x-text="v"></span>
</template>
</div>
</template>
<div class="api-lookup-empty" x-show="!getFilteredCustomers(attr.name, attributeValues[attr.name]).length">No customers found.</div>
</div>
</div>
</div>
</div>
</template>
<template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup' && attr.type !== 'customer_lookup'">
<input class="input" :type="inputType(attr.type)"
:name="`attribute_values[${attr.name}]`"
:value="attributeValues[attr.name] ?? ''"


+ 40
- 2
app/Views/jobs/edit.php Näytä tiedosto

@@ -73,7 +73,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
</div>
<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }">
<label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' || attr.type === 'customer_lookup' }">
<span x-text="attr.name"></span>
<template x-if="attr.type === 'boolean'">
<select class="input"
@@ -123,7 +123,45 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
</div>
</div>
</template>
<template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'">
<template x-if="attr.type === 'customer_lookup'">
<div class="api-lookup-field">
<input class="input" type="text"
:name="`attribute_values[${attr.name}]`"
x-model="attributeValues[attr.name]"
placeholder="Search customers…"
autocomplete="off"
x-on:focus="openCustomerLookup(attr.name)"
x-on:blur="closeCustomerLookup(attr.name)"
x-on:keydown.escape.prevent="closeCustomerLookup(attr.name)">
<div class="api-lookup-dropdown"
x-show="customerLookupOpen[attr.name]"
x-on:mousedown.prevent>
<p class="api-lookup-loading" x-show="customerLookupState[attr.name] === 'loading'">Loading…</p>
<div class="api-lookup-table" x-show="customerLookupState[attr.name] !== 'loading'">
<div class="api-lookup-thead"
:style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`">
<template x-for="f in getCustomerOptions(attr.name).fields" :key="f">
<span x-text="f"></span>
</template>
</div>
<div class="api-lookup-tbody">
<template x-for="rec in getFilteredCustomers(attr.name, attributeValues[attr.name])" :key="rec._primary">
<div class="api-lookup-tr"
:class="{ 'is-selected': attributeValues[attr.name] === rec._primary }"
:style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`"
x-on:click="selectCustomer(attr, rec)">
<template x-for="(v, i) in rec._display" :key="i">
<span x-text="v"></span>
</template>
</div>
</template>
<div class="api-lookup-empty" x-show="!getFilteredCustomers(attr.name, attributeValues[attr.name]).length">No customers found.</div>
</div>
</div>
</div>
</div>
</template>
<template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup' && attr.type !== 'customer_lookup'">
<input class="input" :type="inputType(attr.type)"
:name="`attribute_values[${attr.name}]`"
:value="attributeValues[attr.name] ?? ''"


+ 108
- 20
public/js/app.js Näytä tiedosto

@@ -1203,20 +1203,27 @@ window.jobTypeForm = function (initialAttributes, customerTypes) {
return (a.order || 0) - (b.order || 0);
}).map(function (a) {
return {
name: a.name || '',
type: a.type || 'text',
alias: a.alias || '',
order: 0,
api_url: a.api_url || '',
api_match_field: a.api_match_field || '',
api_auto_fill: a.api_auto_fill || '',
api_format: a.api_format || 'json',
api_return_type: a.api_return_type || 'text',
name: a.name || '',
type: a.type || 'text',
alias: a.alias || '',
order: 0,
api_url: a.api_url || '',
api_match_field: a.api_match_field || '',
api_auto_fill: a.api_auto_fill || '',
api_format: a.api_format || 'json',
api_return_type: a.api_return_type || 'text',
customer_type_id: 0,
};
});

this.attributes.splice.apply(this.attributes, [index, 1].concat(imported));
if (row.type === 'customer_lookup') {
// Keep the lookup row; stamp api_match_field from the customer type, then insert attrs after it.
row.api_match_field = ct.api_match_field || '';
this.attributes.splice.apply(this.attributes, [index + 1, 0].concat(imported));
} else {
// Legacy customer placeholder — replace with expanded attrs.
this.attributes.splice.apply(this.attributes, [index, 1].concat(imported));
}
this.renumberOrder();
},

@@ -1441,10 +1448,13 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
jobTypes: jobTypes,
selectedTypeId: String(initialTypeId || ''),
attributeValues: Object.assign({}, initialValues || {}),
apiLookupState: {},
apiLookupError: {},
apiLookupOptions: {},
apiLookupOpen: {},
apiLookupState: {},
apiLookupError: {},
apiLookupOptions: {},
apiLookupOpen: {},
customerLookupState: {},
customerLookupOptions: {},
customerLookupOpen: {},

get currentType() {
var id = this.selectedTypeId;
@@ -1462,19 +1472,24 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
init() {
var self = this;
this.currentAttributes.forEach(function (attr) {
if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
if (attr.type === 'customer_lookup') { self.fetchCustomers(attr); }
});
},

onTypeChange() {
this.attributeValues = {};
this.apiLookupOptions = {};
this.apiLookupState = {};
this.apiLookupOpen = {};
this.attributeValues = {};
this.apiLookupOptions = {};
this.apiLookupState = {};
this.apiLookupOpen = {};
this.customerLookupOptions = {};
this.customerLookupState = {};
this.customerLookupOpen = {};
var self = this;
this.$nextTick(function () {
self.currentAttributes.forEach(function (attr) {
if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
if (attr.type === 'customer_lookup') { self.fetchCustomers(attr); }
});
});
},
@@ -1623,6 +1638,79 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
});
},

getCustomerOptions(name) {
return this.customerLookupOptions[name] || { fields: [], records: [] };
},

openCustomerLookup(name) {
var o = {}; o[name] = true;
this.customerLookupOpen = Object.assign({}, this.customerLookupOpen, o);
},

closeCustomerLookup(name) {
var o = {}; o[name] = false;
this.customerLookupOpen = Object.assign({}, this.customerLookupOpen, o);
},

getFilteredCustomers(name, search) {
var records = this.getCustomerOptions(name).records;
if (!search) return records;
var term = String(search).toLowerCase();
return records.filter(function (rec) {
return rec._display.some(function (v) {
return String(v).toLowerCase().indexOf(term) !== -1;
});
});
},

selectCustomer(attr, customer) {
var newValues = Object.assign({}, this.attributeValues);
newValues[attr.name] = customer._primary;
var av = customer._raw.attribute_values || {};
Object.keys(av).forEach(function (key) {
newValues[key] = String(av[key] !== null && av[key] !== undefined ? av[key] : '');
});
this.attributeValues = newValues;
this.closeCustomerLookup(attr.name);
},

fetchCustomers(attr) {
var self = this;
var ctId = Number(attr.customer_type_id || 0);
var matchField = attr.api_match_field || '';
if (!ctId) return;

var s = {}; s[attr.name] = 'loading';
var o = {}; o[attr.name] = { fields: [], records: [] };
self.customerLookupState = Object.assign({}, self.customerLookupState, s);
self.customerLookupOptions = Object.assign({}, self.customerLookupOptions, o);

fetch('/api/customers?customer_type_id=' + ctId, { headers: { Accept: 'application/json' } })
.then(function (res) { return res.json(); })
.then(function (rows) {
var ss = {}; ss[attr.name] = 'idle';
if (!Array.isArray(rows) || rows.length === 0) {
self.customerLookupState = Object.assign({}, self.customerLookupState, ss);
return;
}
var attrKeys = Object.keys(rows[0].attribute_values || {});
var records = rows.map(function (c) {
var av = c.attribute_values || {};
var display = attrKeys.map(function (k) { return av[k] !== undefined && av[k] !== null ? String(av[k]) : ''; });
var primary = matchField && av[matchField] !== undefined ? String(av[matchField]) : String(c.id);
return { _primary: primary, _display: display, _raw: c };
});
var oo = {}; oo[attr.name] = { fields: attrKeys, records: records };
self.customerLookupOptions = Object.assign({}, self.customerLookupOptions, oo);
self.customerLookupState = Object.assign({}, self.customerLookupState, ss);
})
.catch(function (err) {
console.error('[customer-lookup] fetch failed:', err);
var cs = {}; cs[attr.name] = 'error';
self.customerLookupState = Object.assign({}, self.customerLookupState, cs);
});
},

confirmDelete(event) {
if (confirm('Delete this job? This cannot be undone.')) {
event.target.submit();


Loading…
Peruuta
Tallenna

Powered by TurnKey Linux.