Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts
T

726 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Dialog for completing a maintenance task with optional notes, cost, duration. */
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import "./ms-date-field";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@property() public taskId = "";
@property() public taskName = "";
@property() public lang = "en";
@property({ type: Array }) public checklist: string[] = [];
@property({ type: Boolean }) public adaptiveEnabled = false;
// v2.20 (#83): task type + unit drive the reading-value field below.
@property() public taskType = "";
@property() public readingUnit = "";
/** #161 phase 2: the task's reading slots — one field each instead of
* the single value; [] keeps the scalar field. */
@property({ attribute: false }) public readings: ReadingSlot[] = [];
/** The task's dated slot snapshots (oldest first) — the "last: …" hint
* and the lower-than-last warning compare against the last value BEFORE
* the completion moment, so a backdated entry is judged by its own
* date rather than by the newest reading. */
@property({ attribute: false }) public readingHistory: ReadingHistoryEntry[] = [];
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #104 follow-up: the buy task's part unit cost — powers the cost
* suggestion (restock qty × unit cost). */
@property({ attribute: false }) public restockUnitCost: number | null = null;
/** Currency symbol for the cost suggestion ("" = plain number). */
@property() public currencySymbol = "";
/** #99: the parts offered on completion — enables the editable "parts used"
* section. Built by `partsForCompletion`: the object's own inventory plus
* every shared pool this task links to (#111), each tagged with its owner. */
@property({ attribute: false }) public parts: LinkedPart[] = [];
/** #99: the task's fixed consumes_parts links (prefill for the section).
* A link may carry an `entry_id` (#111) and MUST keep it through the edit —
* without it the completion would decrement the wrong inventory, or none. */
@property({ attribute: false }) public consumesParts: TaskPartLink[] = [];
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
@property({ type: Array }) public consumesInfo: string[] = [];
/** Details this task demands before it counts as done (v2.44). The backend
* enforces the same list at every completion surface; blocking Save here
* just means the user never has to meet that rejection. */
@property({ type: Array }) public requiredFields: string[] = [];
/** #139: "2/4 · Flip blades" — which phase of the cycle this completion
* records. Purely informative; the backend advances the cursor itself. */
@property() public phaseLabel = "";
/** Proof of presence: the task completes only via NFC/QR scan — the
* dialog says so up front instead of relaying the server's refusal. */
@property({ type: Boolean }) public requireTagScan = false;
/** This open is the fallback of a QR/NFC scan (quick-complete refused for
* missing defaults/details): the completion carries `via_tag_scan` so the
* tag-scan gate counts the scan that got the user here, and the "scan
* required" note stays hidden — they just did. Set by open(); never
* survives into the next open. */
@property({ type: Boolean }) public viaTagScan = false;
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@state() private _duration = "";
@state() private _loading = false;
@state() private _error = "";
@state() private _checklistState: Record<string, boolean> = {};
@state() private _feedback: string = "needed";
/** #161: the photos attached so far (uploads of this session are dropped
* again on Cancel so an abandoned completion leaves no orphans). */
private readonly _photos = new PhotoUploadController(this, {
entryId: () => this.entryId,
hass: () => this.hass,
});
@state() private _readingValue = "";
/** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */
@state() private _readingValues: Record<string, string> = {};
@state() private _restockQty = "";
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */
@state() private _completedAt = "";
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@state() private _usedParts: Record<string, TaskPartLink> = {};
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
* prefill the dialog so nobody re-ticks what is already done. */
@property({ attribute: false }) public checklistPrefill: Record<string, boolean> = {};
public open(opts: { viaTagScan?: boolean } = {}): void {
if (this._open) return;
this._open = true;
// Reset on every open — a plain open after a scan fallback must not
// keep claiming proof of presence.
this.viaTagScan = !!opts.viaTagScan;
this._notes = "";
this._cost = "";
this._duration = "";
this._error = "";
// The dialog's own state is INDEX-keyed (historical shape, flows into the
// history entry as-is) — map the text-keyed in-cycle ticks onto indices.
this._checklistState = Object.fromEntries(
this.checklist
.map((item, i) => [String(i), !!this.checklistPrefill[item]] as const)
.filter(([, done]) => done),
);
this._feedback = "needed";
this._photos.reset();
this._readingValue = "";
this._readingValues = {};
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
this._completedAt = "";
// #99: prefill "parts used" with the task's fixed links — the user can
// untick or adjust before completing. The whole link is kept, entry_id
// included, so a shared pool survives the edit (#111).
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
}
private _toggleCheck(idx: number): void {
const key = String(idx);
this._checklistState = {
...this._checklistState,
[key]: !this._checklistState[key],
};
}
private _setFeedback(value: string): void {
this._feedback = value;
}
private async _complete(): Promise<void> {
this._loading = true;
this._error = "";
this._photos.clearError();
try {
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
}
if (this._duration) {
const dur = parseInt(this._duration, 10);
if (!isNaN(dur) && dur >= 0) data.duration = dur;
}
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photos.photos.length > 0) {
data.photo_doc_ids = this._photos.ids;
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
// opened. Only ever sent when a scan actually happened.
if (this.viaTagScan) {
data.via_tag_scan = true;
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
this._loading = false;
return;
}
// Re-add seconds if the datetime-local input drops them (same
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
}
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty !== "") {
const rq = parseFloat(this._restockQty);
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
// entry_id travels only when the pool is somebody else's (#111), so an
// own-part payload is byte-identical to what shipped before.
if (this.parts.length > 0) {
data.used_parts = Object.values(this._usedParts)
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
.map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
await this.hass.connection.sendMessagePromise(data);
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
} finally {
this._loading = false;
}
}
/** #161 phase 2: one field per slot with the previous value as a hint;
* a value below the last one gets a warning (meters count up — a
* replaced meter or a typo), never a block. */
private _renderReadingField(slot: ReadingSlot, L: string) {
const at = this._completedAt ? new Date(this._completedAt).getTime() : NaN;
const last = lastReadingBefore(this.readingHistory, slot.id, isNaN(at) ? undefined : at);
const unit = slot.unit || this.readingUnit;
const raw = (this._readingValues[slot.id] ?? "").trim();
const num = raw === "" ? NaN : parseFloat(raw.replace(",", "."));
const below = last !== undefined && !isNaN(num) && num < last.value;
const lastText = last !== undefined ? formatNumber(last.value, L, { maximumFractionDigits: 3 }) : "";
return html`
<label class="field reading-field">
<span class="field-label">${slot.name}${unit ? ` (${unit})` : ""}</span>
<input type="text" inputmode="decimal" class="field-input"
placeholder=${last !== undefined ? t("reading_last", L).replace("{value}", lastText) : ""}
.value=${this._readingValues[slot.id] ?? ""}
@input=${(e: Event) => {
this._readingValues = { ...this._readingValues, [slot.id]: (e.target as HTMLInputElement).value };
}} />
${below ? html`<span class="reading-warn">${t("reading_below_last", L).replace("{value}", lastText)}</span>` : nothing}
</label>`;
}
/** Required details the user has not supplied yet (drives Save + markers). */
private get _missingRequired(): string[] {
const filled: Record<string, boolean> = {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photos.photos.length > 0,
// "Who did it" is filled in server-side from the authenticated
// connection (websocket/tasks_actions.py), so the dialog satisfies it
// as long as we ARE a logged-in user. Claiming it is always satisfied
// was how a task requiring "user" ended up unclosable: Save stayed
// enabled and the backend rejected the completion every time.
user: !!this.hass?.user,
};
return this.requiredFields.filter((f) => !filled[f]);
}
/** Marker appended to a required field's label. */
private _req(field: string) {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
/** #104 follow-up: suggested cost derived from the parts this completion
* touches — the SELECTED "parts used" (qty × each part's unit cost) on a
* consuming task, or restock qty × unit cost on a buy task. Null when no
* involved part carries a price. Follows the live selection, so ticking
* a part off updates the suggestion. */
private _partsCostSuggestion(): number | null {
if (this.restockDefault !== null) {
const qty = parseFloat(this._restockQty);
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
return Math.round(this.restockUnitCost * qty * 100) / 100;
}
if (!this.parts.length) return null;
let sum = 0;
let priced = false;
for (const link of Object.values(this._usedParts)) {
const def = this.parts.find(
(pt) => partLinkKey({ part_id: pt.id, entry_id: pt.entry_id }) === partLinkKey(link),
);
if (def?.cost != null) {
sum += def.cost * (link.quantity || 1);
priced = true;
}
}
return priced ? Math.round(sum * 100) / 100 : null;
}
/** The one-click "use ≈ X from parts" chip under the cost field. Hidden
* once the user typed a cost themselves — a suggestion, never an
* overwrite. */
private _renderCostSuggestion(L: string) {
if (this._cost.trim() !== "") return nothing;
const suggestion = this._partsCostSuggestion();
if (suggestion == null || suggestion <= 0) return nothing;
const amount = formatCost(suggestion, this.currencySymbol, L);
return html`<button
type="button"
class="cost-suggestion"
@click=${() => (this._cost = String(Math.round(suggestion * 100) / 100))}
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
}
private _close(): void {
this._open = false;
this._photos.discardOrphans();
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
private _pickCompletedAt(): void {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
const error = this._error || this._photos.errorText(L);
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
${this.phaseLabel ? html`<div class="phase-line">${t("phase_current", L)}: ${this.phaseLabel}</div>` : nothing}
${this.requireTagScan && !this.viaTagScan ? html`<div class="scan-required-note">${t("require_tag_scan_hint", L)}</div>` : nothing}
<div class="content">
${error ? html`<div class="error">${error}</div>` : nothing}
${this.checklist.length > 0 ? html`
<div class="checklist-section">
<label class="checklist-label">${t("checklist", L)}</label>
${this.checklist.map((item, idx) => html`
<label class="checklist-item" @click=${() => this._toggleCheck(idx)}>
<input type="checkbox" .checked=${!!this._checklistState[String(idx)]} />
<span>${item}</span>
</label>
`)}
</div>
` : nothing}
${this.readings.length > 0
? html`<div class="readings-block">
<span class="field-label">${t("readings_section", L)}</span>
${this.readings.map((slot) => this._renderReadingField(slot, L))}
</div>`
: this.taskType === "reading"
? html`
<label class="field">
<span class="field-label">${t("reading_value_label", L)}${this.readingUnit ? ` (${this.readingUnit})` : ""}</span>
<input type="number" step="any" class="field-input"
.value=${this._readingValue}
@input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
${this.parts.length
? html`<div class="used-parts">
<span class="field-label">${t("complete_parts_used", L)}</span>
${this.parts.map((pt) => {
const key = partLinkKey({ part_id: pt.id, entry_id: pt.entry_id });
const link = this._usedParts[key];
const checked = link !== undefined;
const base: TaskPartLink = pt.entry_id
? { part_id: pt.id, quantity: 1, entry_id: pt.entry_id }
: { part_id: pt.id, quantity: 1 };
return html`<div class="used-part-row">
<label class="used-part-check">
<input type="checkbox" .checked=${checked}
@change=${(e: Event) => {
const next = { ...this._usedParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._usedParts = next;
}} />
<span
>${pt.name}${pt.owner_name
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span
>
</label>
${checked
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._usedParts = {
...this._usedParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}} />`
: nothing}
</div>`;
})}
</div>`
: this.consumesInfo.length
? html`<div class="consumes-hint">
${this.consumesInfo.map((line) => html`<div>${line}</div>`)}
</div>`
: nothing}
${this.restockDefault !== null
? html`
<label class="field">
<span class="field-label">${t("restock_quantity_label", L)}</span>
<input type="number" step="0.01" min="0.01" class="field-input"
.value=${this._restockQty}
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
<!-- Native <input>s rather than <ha-textfield>: when this dialog
is opened from a Lovelace card via dialog-mount, ha-textfield
isn't yet registered (HA loads it lazily when its own panels
need it) so the elements render with zero height and the user
only sees the title + Cancel/Complete buttons — the original
bug report. Native inputs always render. -->
<label class="field">
<span class="field-label">${t("notes_optional", L)}${this._req("notes")}</span>
<input type="text" class="field-input"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("cost_optional", L)}${this._req("cost")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
${this._renderCostSuggestion(L)}
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
<div class="field">
<span class="field-label">${t("completed_at_optional", L)}</span>
${this._completedAt
? html`<ms-date-field
kind="datetime"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._completedAt}
@value-changed=${(e: CustomEvent) => (this._completedAt = e.detail.value as string)}
></ms-date-field>`
: html`<button type="button" class="backdate-pick" @click=${this._pickCompletedAt}>
<ha-icon icon="mdi:calendar-clock"></ha-icon>${t("completed_at_pick", L)}
</button>`}
</div>
<div class="field">
<span class="field-label">${t("completion_photos_optional", L)}${this._req("photo")}</span>
${this._photos.photos.length > 0
? html`<div class="photo-strip">
${this._photos.photos.map((p) => html`
<div class="photo-preview">
<img src=${p.preview} alt="" />
<button type="button" class="photo-remove" @click=${() => this._photos.remove(p.id)}
title="${t("remove", L)}">✕</button>
</div>`)}
</div>`
: nothing}
${!this._photos.full
? html`<ms-photo-picker .lang=${L} .busy=${this._photos.uploading} .remaining=${this._photos.remaining}
@files-picked=${(e: CustomEvent<{ files: File[] }>) => this._photos.addFiles(e.detail.files)}
></ms-photo-picker>`
: html`<div class="photo-limit">${t("photos_limit", L).replace("{max}", String(this._photos.max))}</div>`}
</div>
${this.adaptiveEnabled ? html`
<div class="feedback-section">
<label class="feedback-label">${t("was_maintenance_needed", L)}</label>
<div class="feedback-buttons">
<button
class="feedback-btn ${this._feedback === "needed" ? "selected" : ""}"
@click=${() => this._setFeedback("needed")}
>${t("feedback_needed", L)}</button>
<button
class="feedback-btn ${this._feedback === "not_needed" ? "selected" : ""}"
@click=${() => this._setFeedback("not_needed")}
>${t("feedback_not_needed", L)}</button>
<button
class="feedback-btn ${this._feedback === "not_sure" ? "selected" : ""}"
@click=${() => this._setFeedback("not_sure")}
>${t("feedback_not_sure", L)}</button>
</div>
</div>
` : nothing}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading || this._missingRequired.length > 0}
title=${this._missingRequired.length
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
: ""}
>
${this._loading ? t("completing", L) : t("complete", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = [nativeFieldStyles, photoPickerStyles, css`
.req-mark {
color: var(--error-color, #f44336);
margin-left: 2px;
font-weight: 600;
}
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
.cost-suggestion {
align-self: flex-start;
margin-top: 4px;
padding: 0;
border: none;
background: none;
color: var(--primary-color);
font-size: 12.5px;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.scan-required-note {
margin: -4px 0 12px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 152, 0, 0.12);
color: var(--primary-text-color);
font-size: 13px;
}
.phase-line {
margin-top: -8px;
padding-bottom: 12px;
font-size: 13px;
color: var(--secondary-text-color);
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.consumes-hint {
font-size: 13px;
color: var(--secondary-text-color);
border-left: 3px solid var(--primary-color);
padding: 4px 8px;
margin: 4px 0 8px;
}
/* #99: editable per-completion parts selection */
.used-parts { margin: 4px 0 8px; display: flex; flex-direction: column; gap: 4px; }
.used-part-row { display: flex; align-items: center; gap: 8px; }
.used-part-check {
display: flex; align-items: center; gap: 6px; flex: 1;
font-size: 13px; cursor: pointer;
}
.used-part-check input { cursor: pointer; }
/* #111: whose stock this row draws on. Muted but never omitted — an
unlabelled foreign pool is indistinguishable from an own part. */
.used-part-owner { color: var(--secondary-text-color); }
.used-part-qty {
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
/* .field/.field-label/.field-input come from nativeFieldStyles */
/* #161 phase 2: the per-slot reading fields */
.readings-block { display: flex; flex-direction: column; gap: 8px; }
.readings-block > .field-label { margin-bottom: -4px; }
.reading-warn {
font-size: 12px;
color: var(--warning-color, #ff9800);
}
/* .photo-pick / .photo-pickers / .photo-android-hint come from photoPickerStyles */
/* #163: the backdate moment starts EMPTY (= now); the button seeds the
HA date+time picker with the current minute instead of the picker's
own 00:00 default, so a backdated completion never lands at midnight
by accident. */
.backdate-pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px dashed var(--divider-color);
border-radius: 8px;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 13px;
color: var(--secondary-text-color);
width: fit-content;
--mdc-icon-size: 18px;
}
.backdate-pick:hover { border-color: var(--primary-color); }
/* #161: several photos per completion — tiles wrap into a strip,
the two pickers (camera / gallery) sit underneath while there is
room left under the cap. */
.photo-strip {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 8px 0; /* room for the remove badges above the tiles */
}
.photo-limit {
font-size: 12px;
color: var(--secondary-text-color);
}
.photo-preview {
position: relative;
width: fit-content;
}
/* Uniform tiles: a tiny or portrait shot must not collapse the strip. */
.photo-preview img {
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 8px;
display: block;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
}
.photo-remove {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: var(--error-color, #db4437);
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.checklist-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--divider-color);
margin-bottom: 4px;
}
.checklist-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 0;
font-size: 14px;
}
.checklist-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.feedback-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-top: 1px solid var(--divider-color);
}
.feedback-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.feedback-buttons {
display: flex;
gap: 8px;
}
.feedback-btn {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 8px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.feedback-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
}
.feedback-btn.selected {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
border-color: var(--primary-color);
}
`];
}
// Safe registration — avoids duplicate define when both panel and card load
if (!customElements.get("maintenance-complete-dialog")) {
customElements.define("maintenance-complete-dialog", MaintenanceCompleteDialog);
}