*/ protected function casts(): array { return [ 'options' => 'array', ]; } /** * Reduce a time-of-day fallback to H:i:s. * * An empty value never reaches the attribute bag -- not even as null -- * so clearing the time on a definition that already has one silently * leaves the old time in place. A value the parser cannot read becomes * midnight rather than an error. */ public function setTimeAnswerAttribute(mixed $value): void { if ($value) { $this->attributes['time_answer'] = date('H:i:s', strtotime($value)); } } /** * Encode the option list on the way in. * * A set mutator wins over the array cast, so this runs in its place and * encodes whatever arrives: null is stored as the four characters "null", * and a string that is already JSON is encoded a second time and reads * back as a string rather than as the structure it spells. */ public function setOptionsAttribute(mixed $value): void { $this->attributes['options'] = json_encode($value); } /** * The fallback answer, read from the column this field's input type maps * to. A type outside the mapping reads the string column. */ public function getDefaultAnswerAttribute() { $answerColumn = getCustomFieldValueKey($this->type); return $this->{$answerColumn}; } /** * Whether any record has an answer on file for this field. * * Serialized with the definition so the interface can warn before a * delete; nothing on the delete path itself consults it. */ public function getInUseAttribute() { return $this->customFieldValues()->exists(); } /** * The company the field was defined in. */ public function company(): BelongsTo { return $this->belongsTo(Company::class, 'company_id'); } /** * Every answer recorded against this field, whatever record type holds it. */ public function customFieldValues(): HasMany { return $this->hasMany(CustomFieldValue::class, 'custom_field_id'); } /** * Narrow to the company the current request is acting on. * * The company is read from the request header; the scope takes no * argument and cannot be pointed at a different company. */ public function scopeWhereCompany($query) { $company = request()->header('company'); return $query->where('custom_fields.company_id', $company); } /** * Partial match on either name the field goes by, grouped so it stays one * condition when it is combined with others. */ public function scopeWhereSearch($query, $search) { $needle = '%'.$search.'%'; $query->where(function ($grouped) use ($needle) { $grouped->where('label', 'LIKE', $needle) ->orWhere('name', 'LIKE', $needle); }); } /** * Fields attached to one record type. */ public function scopeWhereType($query, $type) { $query->where('custom_fields.model_type', $type); } /** * Apply the listing filters that carry a value. An empty string, a zero * or a null counts as a filter that was not sent. */ public function scopeApplyFilters($query, array $filters) { $wanted = collect($filters); if ($type = $wanted->get('type')) { $query->whereType($type); } if ($search = $wanted->get('search')) { $query->whereSearch($search); } } /** * A page of the requested size, or the whole set for the sentinel limit * "all". */ public function scopePaginateData($query, $limit) { return $limit == 'all' ? $query->get() : $query->paginate($limit); } }