1522 lines
66 KiB
JavaScript
1522 lines
66 KiB
JavaScript
/**
|
||
* TaskMate Parent Dashboard Card
|
||
* Unified parent view: all children's today progress, pending approvals
|
||
* with inline approve/reject, pending reward claims, and quick point adjustments.
|
||
*
|
||
* Version: 1.0.0
|
||
*/
|
||
|
||
const LitElement = customElements.get("hui-masonry-view")
|
||
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
|
||
: Object.getPrototypeOf(customElements.get("hui-view"));
|
||
const html = LitElement.prototype.html;
|
||
const css = LitElement.prototype.css;
|
||
|
||
// Security defense-in-depth: only ever emit our own auth-gated photo endpoint
|
||
// into an href/src. The backend already rejects foreign/crafted photo_url
|
||
// values, but guard here too so a stored bad value can never become a
|
||
// javascript:/external link in a parent's browser.
|
||
const tmSafePhotoUrl = (u) =>
|
||
typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : "";
|
||
|
||
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
|
||
|
||
class TaskMateParentDashboardCard extends LitElement {
|
||
static get properties() {
|
||
return {
|
||
hass: { type: Object },
|
||
config: { type: Object },
|
||
_loading: { type: Object },
|
||
_activeSection: { type: String },
|
||
_expanded: { type: Object },
|
||
};
|
||
}
|
||
|
||
shouldUpdate(changedProps) {
|
||
if (changedProps.has("hass")) {
|
||
return window.__taskmate_hasChanged
|
||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||
: true;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
_t(key, params) {
|
||
const fn = window.__taskmate_localize;
|
||
return fn ? fn(this.hass, key, params) : key;
|
||
}
|
||
|
||
// Build "Chore · Child · date" for the lightbox caption (missing parts drop).
|
||
_photoCaption(chore, child, iso) {
|
||
let when = "";
|
||
if (iso) {
|
||
const d = new Date(iso);
|
||
if (!isNaN(d)) when = d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
return [chore, child, when].filter(Boolean).join(" · ");
|
||
}
|
||
|
||
// Open the shared in-page lightbox; fall through to the href if it's absent.
|
||
_openPhoto(e, url, caption) {
|
||
if (window.__taskmate_lightbox) {
|
||
e.preventDefault();
|
||
window.__taskmate_lightbox(url, caption, this._t("common.close"));
|
||
}
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this._loading = {};
|
||
this._activeSection = "overview";
|
||
this._expanded = {};
|
||
}
|
||
|
||
static get styles() {
|
||
const base = css`
|
||
:host { display: block; }
|
||
ha-card { overflow: hidden; }
|
||
|
||
/* ── Header ── */
|
||
.card-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 18px;
|
||
background: var(--taskmate-header-bg, #c0392b);
|
||
color: white; gap: 12px;
|
||
}
|
||
|
||
.header-content { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
||
.header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
|
||
.header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
.pending-badge {
|
||
background: #e74c3c; color: white;
|
||
border-radius: 12px; padding: 3px 10px;
|
||
font-size: 0.82rem; font-weight: 700;
|
||
display: flex; align-items: center; gap: 4px;
|
||
flex-shrink: 0;
|
||
animation: badge-pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
.pending-badge ha-icon { --mdc-icon-size: 14px; }
|
||
|
||
@keyframes badge-pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); }
|
||
50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); }
|
||
}
|
||
|
||
/* ── Tab nav ── */
|
||
.tab-nav {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--divider-color, #e0e0e0);
|
||
background: var(--secondary-background-color, #f5f5f5);
|
||
/* Flex items refuse to shrink below their content, so on a narrow card
|
||
the last tab used to be clipped off the edge with no way to reach it.
|
||
Wrapping keeps every tab visible without asking the parent to
|
||
discover a horizontal swipe on a card that doesn't look scrollable. */
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.tab-btn {
|
||
/* Grow to fill a wide card, but keep the label intact when narrow. */
|
||
flex: 1 0 auto;
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
padding: 10px 8px;
|
||
background: none; border: none;
|
||
font-size: 0.78rem; font-weight: 600;
|
||
color: var(--secondary-text-color);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
display: flex; align-items: center; justify-content: center; gap: 5px;
|
||
position: relative;
|
||
}
|
||
|
||
.tab-btn ha-icon { --mdc-icon-size: 16px; }
|
||
|
||
.tab-btn.active {
|
||
color: var(--primary-color, #3498db);
|
||
border-bottom-color: var(--primary-color, #3498db);
|
||
}
|
||
|
||
.tab-badge {
|
||
background: #e74c3c; color: white;
|
||
border-radius: 8px; padding: 1px 5px;
|
||
font-size: 0.65rem; font-weight: 700;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* ── Content ── */
|
||
.tab-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||
|
||
/* ── Child overview tiles ── */
|
||
.child-tile {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 12px 14px;
|
||
background: var(--card-background-color, #fff);
|
||
border: 1px solid var(--divider-color, #e0e0e0);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.child-avatar {
|
||
width: 42px; height: 42px; min-width: 42px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.child-avatar ha-icon { --mdc-icon-size: 26px; color: white; }
|
||
|
||
.child-tile-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||
|
||
.child-tile-header {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||
}
|
||
|
||
.child-tile-name {
|
||
font-size: 0.95rem; font-weight: 600;
|
||
color: var(--primary-text-color);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
|
||
.points-pill {
|
||
display: flex; align-items: center; gap: 3px;
|
||
background: rgba(241,196,15,0.15);
|
||
color: #e67e22; border-radius: 10px;
|
||
padding: 2px 8px; font-size: 0.8rem; font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.points-pill ha-icon { --mdc-icon-size: 13px; color: #f1c40f; }
|
||
|
||
.progress-row { display: flex; align-items: center; gap: 8px; }
|
||
|
||
.progress-bar {
|
||
flex: 1; height: 7px;
|
||
background: var(--divider-color, #e0e0e0);
|
||
border-radius: 4px; overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%; border-radius: 4px;
|
||
transition: width 0.4s ease;
|
||
}
|
||
|
||
.progress-fill.complete { background: linear-gradient(90deg, #27ae60, #2ecc71); }
|
||
.progress-fill.partial { background: linear-gradient(90deg, #3498db, #2980b9); }
|
||
.progress-fill.none { width: 0 !important; }
|
||
|
||
.progress-label {
|
||
font-size: 0.75rem; font-weight: 600;
|
||
color: var(--secondary-text-color);
|
||
white-space: nowrap; min-width: 32px; text-align: right;
|
||
}
|
||
|
||
/* ── Approval items ── */
|
||
.approval-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 14px;
|
||
background: var(--card-background-color, #fff);
|
||
border: 1px solid var(--divider-color, #e0e0e0);
|
||
border-radius: 12px;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.approval-item.loading { opacity: 0.5; pointer-events: none; }
|
||
|
||
.approval-child-avatar {
|
||
width: 38px; height: 38px; min-width: 38px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.approval-child-avatar ha-icon { --mdc-icon-size: 22px; color: white; }
|
||
|
||
.approval-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||
.approval-photo { display: inline-block; margin-top: 4px; line-height: 0; }
|
||
.approval-photo img { width: 48px; height: 48px; object-fit: cover; border-radius: 8px; border: 1px solid var(--divider-color, #e0e0e0); }
|
||
|
||
.approval-chore {
|
||
font-size: 0.9rem; font-weight: 600;
|
||
color: var(--primary-text-color);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
|
||
.approval-meta {
|
||
font-size: 0.75rem; color: var(--secondary-text-color);
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
|
||
.approval-points {
|
||
display: flex; align-items: center; gap: 2px;
|
||
font-weight: 600; color: #e67e22;
|
||
}
|
||
|
||
.approval-points ha-icon { --mdc-icon-size: 12px; color: #f1c40f; }
|
||
|
||
.approval-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||
|
||
.btn-approve, .btn-reject {
|
||
width: 34px; height: 34px;
|
||
border-radius: 50%; border: none; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: transform 0.1s, box-shadow 0.1s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-approve {
|
||
background: linear-gradient(135deg, #27ae60, #2ecc71);
|
||
color: white; box-shadow: 0 2px 8px rgba(46,204,113,0.3);
|
||
}
|
||
|
||
.btn-reject {
|
||
background: linear-gradient(135deg, #c0392b, #e74c3c);
|
||
color: white; box-shadow: 0 2px 8px rgba(231,76,60,0.3);
|
||
}
|
||
|
||
.btn-approve:hover { transform: scale(1.1); }
|
||
.btn-reject:hover { transform: scale(1.1); }
|
||
.btn-approve ha-icon, .btn-reject ha-icon { --mdc-icon-size: 18px; }
|
||
|
||
/* ── Reward claim items ── */
|
||
.claim-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 14px;
|
||
background: var(--card-background-color, #fff);
|
||
border: 1px solid rgba(155,89,182,0.3);
|
||
border-radius: 12px;
|
||
background: rgba(155,89,182,0.04);
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.claim-item.loading { opacity: 0.5; pointer-events: none; }
|
||
|
||
.claim-icon-wrap {
|
||
width: 38px; height: 38px; min-width: 38px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #9b59b6, #8e44ad);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.claim-icon-wrap ha-icon { --mdc-icon-size: 22px; color: white; }
|
||
|
||
.claim-info { flex: 1; min-width: 0; }
|
||
|
||
.claim-reward-name {
|
||
font-size: 0.9rem; font-weight: 600;
|
||
color: var(--primary-text-color);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
|
||
.claim-meta {
|
||
font-size: 0.75rem; color: var(--secondary-text-color); margin-top: 2px;
|
||
}
|
||
|
||
/* ── Quick points ── */
|
||
.quick-points-row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 14px;
|
||
background: var(--card-background-color, #fff);
|
||
border: 1px solid var(--divider-color, #e0e0e0);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.qp-avatar {
|
||
width: 38px; height: 38px; min-width: 38px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.qp-avatar ha-icon { --mdc-icon-size: 22px; color: white; }
|
||
|
||
.qp-name {
|
||
flex: 1; font-size: 0.9rem; font-weight: 600;
|
||
color: var(--primary-text-color);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
|
||
.qp-points {
|
||
font-size: 1rem; font-weight: 700;
|
||
color: #9b59b6; white-space: nowrap;
|
||
display: flex; align-items: center; gap: 3px;
|
||
}
|
||
|
||
.qp-points ha-icon { --mdc-icon-size: 14px; color: #f1c40f; }
|
||
|
||
.qp-actions { display: flex; gap: 6px; }
|
||
|
||
.btn-add, .btn-remove {
|
||
width: 32px; height: 32px;
|
||
border-radius: 50%; border: none; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: transform 0.1s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-add {
|
||
background: linear-gradient(135deg, #27ae60, #2ecc71);
|
||
color: white; box-shadow: 0 2px 6px rgba(46,204,113,0.3);
|
||
}
|
||
|
||
.btn-remove {
|
||
background: linear-gradient(135deg, #c0392b, #e74c3c);
|
||
color: white; box-shadow: 0 2px 6px rgba(231,76,60,0.3);
|
||
}
|
||
|
||
.btn-add:hover, .btn-remove:hover { transform: scale(1.1); }
|
||
.btn-add ha-icon, .btn-remove ha-icon { --mdc-icon-size: 16px; }
|
||
|
||
/* ── Complete on behalf (admin) ── */
|
||
.tm-outstanding { margin-top: 8px; border-top: 1px dashed var(--divider-color, #e0e0e0); padding-top: 8px; }
|
||
.tm-outstanding-hdr { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 6px; }
|
||
.tm-outstanding-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; }
|
||
.tm-outstanding-row + .tm-outstanding-row { border-top: 1px solid var(--divider-color, #eee); }
|
||
.tm-outstanding-name { flex: 1; font-size: 0.9rem; }
|
||
.tm-outstanding-pts { opacity: 0.6; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 2px; }
|
||
.btn-complete-behalf { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; border: none; cursor: pointer; background: #2e9e5b; color: #fff; font: 600 0.75rem/1 inherit; padding: 6px 10px; border-radius: 8px; }
|
||
.btn-complete-behalf:hover { background: #27894e; }
|
||
.btn-complete-behalf:active { transform: scale(0.96); }
|
||
.btn-complete-behalf[disabled] { opacity: 0.5; pointer-events: none; }
|
||
.child-tile.tm-expandable .child-tile-main { cursor: pointer; }
|
||
.tm-all-done { font-size: 0.85rem; opacity: 0.6; font-style: italic; padding: 4px 0; }
|
||
|
||
/* ── Empty state ── */
|
||
.empty-section {
|
||
display: flex; flex-direction: column; align-items: center;
|
||
padding: 24px 16px; text-align: center; gap: 8px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
|
||
.empty-section ha-icon { --mdc-icon-size: 40px; opacity: 0.35; }
|
||
.empty-section span { font-size: 0.9rem; }
|
||
|
||
.timed-sessions-section {
|
||
padding: 8px 0; margin-bottom: 8px;
|
||
border-bottom: 1px solid var(--divider-color, #e0e0e0);
|
||
}
|
||
.timed-session-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 0; font-size: 0.9rem;
|
||
}
|
||
.timed-session-row ha-icon { --mdc-icon-size: 18px; }
|
||
.paused-tag {
|
||
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
|
||
color: #e67e22; background: rgba(230, 126, 34, 0.12);
|
||
padding: 2px 6px; border-radius: 4px;
|
||
}
|
||
|
||
.error-state {
|
||
display: flex; flex-direction: column; align-items: center;
|
||
justify-content: center; padding: 40px 20px;
|
||
color: var(--error-color, #f44336); text-align: center;
|
||
}
|
||
|
||
.error-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||
|
||
@media (max-width: 480px) {
|
||
.card-header { padding: 12px 14px; }
|
||
.tab-btn { font-size: 0.72rem; padding: 8px 6px; }
|
||
.tab-content { padding: 10px; gap: 8px; }
|
||
.approval-item, .claim-item, .quick-points-row, .child-tile { padding: 10px 12px; }
|
||
}
|
||
|
||
/* ── Designed styles (playroom / console / cleanpro) ──
|
||
Shared .tmd kit + tokens come from taskmate-design.js styles().
|
||
Only card-specific layout classes are declared here. */
|
||
/* Tab bar */
|
||
.pd-tabs { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 13px; }
|
||
.pd-tab { font: inherit; font-family: var(--tmd-font-display); font-weight: 800;
|
||
font-size: 13px; border: 0; cursor: pointer; padding: 8px 13px; border-radius: 999px;
|
||
background: var(--tmd-surface-2); color: var(--tmd-text); border: 1px solid var(--tmd-border);
|
||
display: inline-flex; align-items: center; gap: 6px; }
|
||
.pd-tab.active { background: var(--tmd-accent); color: #fff; border-color: transparent; }
|
||
:host([data-tm-design="console"]) .pd-tab.active { color: #06101c;
|
||
background: linear-gradient(135deg, var(--tmd-accent), color-mix(in srgb,var(--tmd-accent) 60%,var(--tmd-accent2)));
|
||
box-shadow: 0 0 14px color-mix(in srgb,var(--tmd-accent) 45%,transparent); }
|
||
.pd-tab .tab-cnt { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
|
||
background: var(--tmd-bad); color: #fff; font-size: 11px; font-weight: 800;
|
||
display: grid; place-items: center; }
|
||
.pd-tab.active .tab-cnt { background: rgba(255,255,255,.3); }
|
||
/* Console tab bar segmented */
|
||
.pd-tabs-cn { display: flex; gap: 0; background: #0b1424; border: 1px solid var(--tmd-border);
|
||
border-radius: 8px; padding: 3px; margin-bottom: 13px; }
|
||
.pd-tab-cn { flex: 1; text-align: center; padding: 7px 4px; border-radius: 6px; border: 0;
|
||
cursor: pointer; background: transparent; color: var(--tmd-dim);
|
||
font-family: var(--tmd-font-mono); font-size: 11px; font-weight: 700; letter-spacing: .04em; }
|
||
.pd-tab-cn.active { background: var(--tmd-surface-2); color: var(--tmd-accent); font-weight: 800; }
|
||
/* Clean Pro tab bar underline */
|
||
.pd-tabs-cp { display: flex; gap: 0; border-bottom: 1px solid var(--tmd-border); margin: -2px 0 14px; }
|
||
.pd-tab-cp { padding: 8px 12px; border: 0; background: transparent; cursor: pointer;
|
||
font-family: var(--tmd-font-body); font-weight: 700; font-size: 13.5px; color: var(--tmd-dim);
|
||
border-bottom: 2px solid transparent; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 6px; }
|
||
.pd-tab-cp.active { color: var(--tmd-accent); border-bottom-color: var(--tmd-accent); }
|
||
.pd-tab-cp .tab-cnt { padding: 1px 7px; border-radius: 999px; background: var(--tmd-bad);
|
||
color: #fff; font-size: 11px; font-weight: 800; }
|
||
|
||
/* Overview rows */
|
||
.pd-ov-pl { background: var(--tmd-surface-2); border-radius: 18px; padding: 12px; }
|
||
.pd-ov-pl + .pd-ov-pl { margin-top: 10px; }
|
||
.pd-ov-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
|
||
border-radius: 10px; padding: 11px; }
|
||
.pd-ov-cn + .pd-ov-cn { margin-top: 9px; }
|
||
.pd-ov-cp { padding: 9px 0; }
|
||
.pd-name { font-weight: 800; }
|
||
.pd-name-cp { font-weight: 600; }
|
||
.pd-sub { font-size: 12.5px; }
|
||
.pd-pts { font-size: 20px; color: var(--tmd-accent); }
|
||
.pd-pts-cn { font-family: var(--tmd-font-mono); font-weight: 800; font-size: 18px; color: var(--tmd-accent); }
|
||
.pd-mini { font-family: var(--tmd-font-mono); font-size: 10px; }
|
||
|
||
/* Generic designed item rows (approvals / claims / points) */
|
||
.pd-item { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
|
||
border-radius: var(--tmd-radius-sm); padding: 11px; }
|
||
.pd-item + .pd-item { margin-top: 9px; }
|
||
.pd-item-title { font-weight: 700; }
|
||
.pd-item-sub { font-size: 12px; }
|
||
.pd-acts { display: flex; gap: 6px; flex: none; }
|
||
.pd-photo { width: 36px; height: 36px; border-radius: 8px; overflow: hidden;
|
||
background: var(--tmd-surface); border: 1px solid var(--tmd-border); display: grid;
|
||
place-items: center; font-size: 15px; flex: none; }
|
||
.pd-photo img { width: 100%; height: 100%; object-fit: cover; }
|
||
.pd-photo-link { line-height: 0; text-decoration: none; }
|
||
.pd-gold-soft { color: var(--tmd-gold); }
|
||
`;
|
||
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
||
? window.__taskmate_design.styles() : null;
|
||
return tokens ? [tokens, base] : base;
|
||
}
|
||
|
||
setConfig(config) {
|
||
if (!config.entity) throw new Error("Please define an entity");
|
||
this.config = {
|
||
title: "Parent Dashboard",
|
||
quick_points_amount: 5,
|
||
show_claims: true,
|
||
header_color: '#c0392b',
|
||
...config,
|
||
};
|
||
}
|
||
|
||
getCardSize() { return 6; }
|
||
static getConfigElement() { return document.createElement("taskmate-parent-dashboard-card-editor"); }
|
||
static getStubConfig() {
|
||
return { entity: "sensor.taskmate_overview", title: "Parent Dashboard" };
|
||
}
|
||
|
||
render() {
|
||
if (!this.hass || !this.config) return html``;
|
||
|
||
const design = window.__taskmate_design
|
||
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
|
||
: "classic";
|
||
if (design !== "classic") return this._renderDesigned(design);
|
||
|
||
const entity = this.hass.states[this.config.entity];
|
||
if (!entity) return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div></ha-card>`;
|
||
if (entity.state === "unavailable" || entity.state === "unknown") return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.unavailable')}</div></div></ha-card>`;
|
||
|
||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||
const children = attrs.children || [];
|
||
const chores = attrs.chores || [];
|
||
const completions = attrs.todays_completions || [];
|
||
// Pending approvals come from the full pending list (not filtered to
|
||
// today) so a completion left pending from a previous day still shows an
|
||
// approve button after a recurring chore resets. `completions` stays
|
||
// today-only for the overview tab's activity display.
|
||
const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved);
|
||
const pendingRewardClaims = attrs.pending_reward_claims || [];
|
||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||
const pointsName = attrs.points_name || this._t('common.points');
|
||
const totalPending = pendingCompletions.length + pendingRewardClaims.length;
|
||
|
||
const rotationChores = chores.filter(c => ['alternating', 'random', 'balanced'].includes(c.assignment_mode || 'everyone'));
|
||
|
||
const tabs = [
|
||
{ id: "overview", label: this._t('dashboard.tab_overview'), icon: "mdi:view-dashboard" },
|
||
{ id: "approvals", label: this._t('dashboard.tab_approvals'), icon: "mdi:check-circle", count: pendingCompletions.length },
|
||
{ id: "points", label: this._t('dashboard.tab_points'), icon: "mdi:star-plus" },
|
||
];
|
||
|
||
if (rotationChores.length > 0) {
|
||
tabs.splice(1, 0, { id: "rotation", label: this._t('dashboard.tab_rotation', {}, 'Rotation'), icon: "mdi:rotate-3d-variant" });
|
||
}
|
||
|
||
if (this.config.show_claims) {
|
||
const insertAt = tabs.findIndex(t => t.id === "points");
|
||
tabs.splice(insertAt, 0, { id: "claims", label: this._t('dashboard.tab_claims'), icon: "mdi:gift", count: pendingRewardClaims.length });
|
||
}
|
||
|
||
return html`
|
||
<ha-card>
|
||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#c0392b')}; }</style>
|
||
<div class="card-header">
|
||
<div class="header-content">
|
||
<ha-icon class="header-icon" icon="mdi:shield-account"></ha-icon>
|
||
<span class="header-title">${this.config.title || this._t('dashboard.default_title')}</span>
|
||
</div>
|
||
${totalPending > 0 ? html`
|
||
<div class="pending-badge">
|
||
<ha-icon icon="mdi:clock-alert"></ha-icon>
|
||
${totalPending}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="tab-nav">
|
||
${tabs.map(tab => html`
|
||
<button
|
||
class="tab-btn ${this._activeSection === tab.id ? 'active' : ''}"
|
||
@click="${() => { this._activeSection = tab.id; this.requestUpdate(); }}"
|
||
>
|
||
<ha-icon icon="${tab.icon}"></ha-icon>
|
||
${tab.label}
|
||
${tab.count ? html`<span class="tab-badge">${tab.count}</span>` : ''}
|
||
</button>
|
||
`)}
|
||
</div>
|
||
|
||
<div class="tab-content">
|
||
${this._activeSection === "overview" ? this._renderOverview(children, chores, completions, pointsIcon, pointsName) : ''}
|
||
${this._activeSection === "rotation" ? this._renderRotation(rotationChores, children, attrs) : ''}
|
||
${this._activeSection === "approvals" ? this._renderApprovals(pendingCompletions, children, chores, pointsIcon) : ''}
|
||
${this._activeSection === "claims" ? this._renderClaims(pendingRewardClaims, pointsIcon) : ''}
|
||
${this._activeSection === "points" ? this._renderPoints(children, pointsIcon, pointsName) : ''}
|
||
</div>
|
||
</ha-card>
|
||
`;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||
Ported from docs/design/redesigns/frag/12-parent-dashboard.html.
|
||
Preserves the tab state machine (_activeSection) and reuses every
|
||
existing handler.
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||
|
||
_av(name, avatar, tone, size) {
|
||
const a = avatar || "";
|
||
const inner = a.startsWith("mdi:")
|
||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||
: a
|
||
? html`<img src="${a}" alt="${name || ''}">`
|
||
: (name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||
}
|
||
|
||
// Per-child today progress (approved / total due today), mirroring the
|
||
// classic _renderOverview filter logic, for the designed overview tab.
|
||
_designChildProgress(child, chores, completions, attrs) {
|
||
const todayDow = attrs.today_day_of_week ||
|
||
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
|
||
const availability = attrs.chore_availability || {};
|
||
const childChores = chores.filter(c => {
|
||
if (c.enabled === false) return false;
|
||
if ((c.disabled_for || []).includes(child.id)) return false;
|
||
const at = c.assigned_to || [];
|
||
const assigned = at.length === 0 || at.includes(child.id);
|
||
if (!assigned) return false;
|
||
const perChild = availability[c.id];
|
||
if (c.schedule_mode === 'one_shot') {
|
||
if (perChild && perChild[child.id] === false) return false;
|
||
}
|
||
if (c.schedule_mode === 'specific_days') {
|
||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||
}
|
||
if (c.schedule_mode === 'recurring') {
|
||
if (perChild && perChild[child.id] === false) return false;
|
||
}
|
||
return true;
|
||
});
|
||
const childChoreIds = new Set(childChores.map(c => c.id));
|
||
const approved = completions.filter(c => c.child_id === child.id && c.approved && childChoreIds.has(c.chore_id)).length;
|
||
const total = childChores.length;
|
||
const pct = total > 0 ? Math.min(100, Math.round((approved / total) * 100)) : 0;
|
||
return { approved, total, pct };
|
||
}
|
||
|
||
_renderDesigned(design) {
|
||
const hd = _safeColor(this.config.header_color, '#c0392b');
|
||
const entity = this.hass.states[this.config.entity];
|
||
|
||
if (!entity) {
|
||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||
${this._dHeader(hd, 0)}
|
||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div>
|
||
</ha-card>`;
|
||
}
|
||
if (entity.state === "unavailable" || entity.state === "unknown") {
|
||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||
${this._dHeader(hd, 0)}
|
||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.unavailable')}</div></div>
|
||
</ha-card>`;
|
||
}
|
||
|
||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||
const children = attrs.children || [];
|
||
const chores = attrs.chores || [];
|
||
const completions = attrs.todays_completions || [];
|
||
const pendingCompletions = attrs.chore_completions || completions.filter(c => !c.approved);
|
||
const pendingRewardClaims = attrs.pending_reward_claims || [];
|
||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||
const pointsName = attrs.points_name || this._t('common.points');
|
||
const totalPending = pendingCompletions.length + pendingRewardClaims.length;
|
||
|
||
const rotationChores = chores.filter(c => ['alternating', 'random', 'balanced'].includes(c.assignment_mode || 'everyone'));
|
||
|
||
const tabs = [
|
||
{ id: "overview", label: this._t('dashboard.tab_overview') },
|
||
{ id: "approvals", label: this._t('dashboard.tab_approvals'), count: pendingCompletions.length },
|
||
{ id: "points", label: this._t('dashboard.tab_points') },
|
||
];
|
||
if (rotationChores.length > 0) {
|
||
tabs.splice(1, 0, { id: "rotation", label: this._t('dashboard.tab_rotation', {}, 'Rotation') });
|
||
}
|
||
if (this.config.show_claims) {
|
||
const insertAt = tabs.findIndex(t => t.id === "points");
|
||
tabs.splice(insertAt, 0, { id: "claims", label: this._t('dashboard.tab_claims'), count: pendingRewardClaims.length });
|
||
}
|
||
|
||
// Guard: if the active section is no longer present (e.g. rotation/claims
|
||
// toggled off), fall back to overview.
|
||
if (!tabs.some(t => t.id === this._activeSection)) this._activeSection = "overview";
|
||
|
||
const tabBar =
|
||
design === "console" ? this._dTabsConsole(tabs) :
|
||
design === "cleanpro" ? this._dTabsCleanpro(tabs) :
|
||
this._dTabsPlayroom(tabs);
|
||
|
||
let body;
|
||
const sec = this._activeSection;
|
||
if (sec === "overview") body = this._dOverview(design, children, chores, completions, attrs, pointsIcon);
|
||
else if (sec === "rotation") body = this._dRotation(design, rotationChores, children, attrs);
|
||
else if (sec === "approvals") body = this._dApprovals(design, pendingCompletions, children, chores, pointsIcon);
|
||
else if (sec === "claims") body = this._dClaims(design, pendingRewardClaims, pointsIcon);
|
||
else if (sec === "points") body = this._dPoints(design, children, pointsIcon, pointsName);
|
||
|
||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||
${this._dHeader(hd, totalPending)}
|
||
<div class="tmd-bd">
|
||
${tabBar}
|
||
${body}
|
||
</div>
|
||
</ha-card>`;
|
||
}
|
||
|
||
_dHeader(hd, count) {
|
||
const title = this.config.title || this._t('dashboard.default_title');
|
||
return html`
|
||
<div class="tmd-hd">
|
||
<span class="ic">🏠</span>
|
||
<span class="tt">${title}</span>
|
||
${count > 0 ? html`<span class="cnt">${count}</span>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
_switchTab(id) { this._activeSection = id; this.requestUpdate(); }
|
||
|
||
_dTabsPlayroom(tabs) {
|
||
return html`
|
||
<div class="pd-tabs">
|
||
${tabs.map(t => html`
|
||
<button class="pd-tab ${this._activeSection === t.id ? 'active' : ''}"
|
||
@click="${() => this._switchTab(t.id)}">
|
||
${t.label}${t.count ? html`<span class="tab-cnt">${t.count}</span>` : ""}
|
||
</button>`)}
|
||
</div>`;
|
||
}
|
||
|
||
_dTabsConsole(tabs) {
|
||
return html`
|
||
<div class="pd-tabs-cn">
|
||
${tabs.map(t => html`
|
||
<button class="pd-tab-cn ${this._activeSection === t.id ? 'active' : ''}"
|
||
@click="${() => this._switchTab(t.id)}">
|
||
${(t.label || '').toUpperCase()}${t.count ? html`·${t.count}` : ""}
|
||
</button>`)}
|
||
</div>`;
|
||
}
|
||
|
||
_dTabsCleanpro(tabs) {
|
||
return html`
|
||
<div class="pd-tabs-cp">
|
||
${tabs.map(t => html`
|
||
<button class="pd-tab-cp ${this._activeSection === t.id ? 'active' : ''}"
|
||
@click="${() => this._switchTab(t.id)}">
|
||
${t.label}${t.count ? html`<span class="tab-cnt">${t.count}</span>` : ""}
|
||
</button>`)}
|
||
</div>`;
|
||
}
|
||
|
||
_dEmpty(msg) { return html`<div class="tmd-empty">${msg}</div>`; }
|
||
|
||
_dOverview(design, children, chores, completions, attrs, pointsIcon) {
|
||
if (!children.length) return this._dEmpty(this._t('dashboard.empty_no_children'));
|
||
return html`${children.map((child, i) => {
|
||
const tone = this._designTone(i);
|
||
const { approved, total, pct } = this._designChildProgress(child, chores, completions, attrs);
|
||
if (design === "console") {
|
||
return html`
|
||
<div class="row pd-ov-cn" style="--ac:${tone}">
|
||
${this._av(child.name, child.avatar, tone, 34)}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="pd-name">${child.name} <span class="muted pd-mini">${approved}/${total}</span></div>
|
||
<div class="bar" style="margin-top:6px"><i style="width:${pct}%"></i></div>
|
||
</div>
|
||
<div class="pd-pts-cn">${child.points}</div>
|
||
</div>`;
|
||
}
|
||
if (design === "cleanpro") {
|
||
const isParent = window.__taskmate_is_parent(this.hass);
|
||
return html`
|
||
<div class="row pd-ov-cp" style="--ac:${tone}">
|
||
${this._av(child.name, child.avatar, tone, 36)}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="row" style="justify-content:space-between">
|
||
<span class="pd-name-cp">${child.name}</span>
|
||
<span class="muted" style="font-size:12px">${approved}/${total} · ${child.points} ${this._t('common.points')}</span>
|
||
</div>
|
||
<div class="bar" style="margin-top:7px;height:8px"><i style="width:${pct}%"></i></div>
|
||
</div>
|
||
${isParent ? html`<button class="btn ghost round sm"
|
||
title="${this._t('dashboard.tab_points')}"
|
||
@click="${() => this._switchTab('points')}">+</button>` : ""}
|
||
</div>
|
||
${i < children.length - 1 ? html`<div class="divide" style="margin:0"></div>` : ""}`;
|
||
}
|
||
// playroom
|
||
return html`
|
||
<div class="pd-ov-pl" style="--ac:${tone}">
|
||
<div class="row">
|
||
${this._av(child.name, child.avatar, tone, 42)}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="pd-name">${child.name}</div>
|
||
<div class="muted pd-sub">${this._t('dashboard.chores_done', { done: approved, total }, `${approved} / ${total} chores done`)}</div>
|
||
</div>
|
||
<div class="big pd-pts">${child.points}⭐</div>
|
||
</div>
|
||
<div class="bar" style="margin-top:9px"><i style="width:${pct}%"></i></div>
|
||
</div>`;
|
||
})}`;
|
||
}
|
||
|
||
_dApprovals(design, pending, children, chores, pointsIcon) {
|
||
if (!pending.length) return this._dEmpty(this._t('dashboard.empty_approvals'));
|
||
const childMap = {}; children.forEach(c => { childMap[c.id] = c; });
|
||
const choreMap = {}; chores.forEach(c => { choreMap[c.id] = c; });
|
||
return html`${pending.map((comp, i) => {
|
||
const tone = this._designTone(i);
|
||
const child = childMap[comp.child_id];
|
||
const isLoading = !!this._loading[comp.completion_id];
|
||
const time = comp.completed_at
|
||
? new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
|
||
const pts = comp.points || choreMap[comp.chore_id]?.points || 0;
|
||
const photoUrl = tmSafePhotoUrl(comp.photo_url);
|
||
const photoCap = this._photoCaption(comp.chore_name, child && child.name, comp.completed_at);
|
||
const photo = photoUrl
|
||
? html`<a class="pd-photo-link" href="${photoUrl}" target="_blank" rel="noopener"
|
||
@click="${(e) => this._openPhoto(e, photoUrl, photoCap)}"
|
||
title="${this._t('approvals.view_photo') || 'View photo'}"><div class="pd-photo"><img src="${photoUrl}" alt=""></div></a>`
|
||
: "";
|
||
const acts = html`
|
||
<button class="btn good sm round" ?disabled="${isLoading}" title="${this._t('common.approve')}"
|
||
@click="${() => this._handleApprove(comp.completion_id)}">✓</button>
|
||
<button class="btn bad sm round" ?disabled="${isLoading}" title="${this._t('common.reject')}"
|
||
@click="${() => this._handleReject(comp.completion_id)}">✕</button>`;
|
||
return html`
|
||
<div class="row pd-item ${isLoading ? 'loading' : ''}" style="--ac:${tone}">
|
||
${this._av(child?.name || '?', child?.avatar, tone, 36)}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="pd-item-title">${comp.chore_name || choreMap[comp.chore_id]?.name || this._t('common.unknown')}</div>
|
||
<div class="muted pd-item-sub">${child?.name || this._t('common.unknown')}${time ? ` · ${time}` : ""} · +${pts}</div>
|
||
</div>
|
||
${photo}
|
||
<div class="pd-acts">${acts}</div>
|
||
</div>`;
|
||
})}`;
|
||
}
|
||
|
||
_dClaims(design, claims, pointsIcon) {
|
||
if (!claims.length) return this._dEmpty(this._t('dashboard.empty_claims'));
|
||
return html`${claims.map((claim, i) => {
|
||
const tone = this._designTone(i);
|
||
const isLoading = !!this._loading[claim.claim_id];
|
||
const time = claim.claimed_at
|
||
? new Date(claim.claimed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
|
||
return html`
|
||
<div class="row pd-item ${isLoading ? 'loading' : ''}" style="--ac:${tone}">
|
||
<div class="pd-photo"><ha-icon icon="${claim.reward_icon || 'mdi:gift'}"></ha-icon></div>
|
||
<div style="flex:1;min-width:0">
|
||
<div class="pd-item-title">${claim.reward_name}</div>
|
||
<div class="muted pd-item-sub">${claim.child_name}${time ? ` · ${time}` : ""} · ${claim.cost}</div>
|
||
</div>
|
||
<div class="pd-acts">
|
||
<button class="btn good sm round" ?disabled="${isLoading}" title="${this._t('common.approve')}"
|
||
@click="${() => this._handleApproveReward(claim.claim_id)}">✓</button>
|
||
<button class="btn bad sm round" ?disabled="${isLoading}" title="${this._t('common.reject')}"
|
||
@click="${() => this._handleRejectReward(claim.claim_id)}">✕</button>
|
||
</div>
|
||
</div>`;
|
||
})}`;
|
||
}
|
||
|
||
_dRotation(design, rotationChores, children, attrs) {
|
||
if (!rotationChores.length) {
|
||
return this._dEmpty(this._t('dashboard.empty_rotation', {}, 'No rotating chores configured'));
|
||
}
|
||
const childById = Object.fromEntries((children || []).map(c => [c.id, c]));
|
||
const groups = attrs.task_groups || [];
|
||
const groupByChoreId = {};
|
||
for (const g of groups) for (const cid of (g.chore_ids || [])) groupByChoreId[cid] = g;
|
||
const unknown = this._t('dashboard.rotation_unassigned', {}, 'Nobody');
|
||
return html`${rotationChores.map((chore, i) => {
|
||
const tone = this._designTone(i);
|
||
const currentChild = childById[chore.assignment_current_child_id || ''];
|
||
const pool = (chore.assigned_to && chore.assigned_to.length) ? chore.assigned_to : (children || []).map(c => c.id);
|
||
const group = groupByChoreId[chore.id];
|
||
const isSticky = group && group.policy === 'sticky';
|
||
const isStickyFollower = isSticky && group.chore_ids[0] !== chore.id;
|
||
const skipDisabled = pool.length <= 1 || isStickyFollower;
|
||
const key = `skip_${chore.id}`;
|
||
const loading = !!this._loading[key];
|
||
return html`
|
||
<div class="row pd-item ${loading ? 'loading' : ''}" style="--ac:${tone}">
|
||
${this._av(currentChild?.name || '?', currentChild?.avatar || 'mdi:rotate-3d-variant', tone, 36)}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="pd-item-title">${chore.name}</div>
|
||
<div class="muted pd-item-sub">${currentChild?.name || unknown} · ${chore.assignment_mode}${group ? ` · ${group.policy === 'sticky' ? '🔗' : '🔀'} ${group.name}` : ""}</div>
|
||
</div>
|
||
<div class="pd-acts">
|
||
<button class="btn ghost sm round" ?disabled="${skipDisabled}"
|
||
title="${this._t('dashboard.rotation_skip_hint', {}, 'Skip current child and move to the next in rotation (today only)')}"
|
||
@click="${() => this._handleSkip(chore.id)}">⏭</button>
|
||
</div>
|
||
</div>`;
|
||
})}`;
|
||
}
|
||
|
||
_dPoints(design, children, pointsIcon, pointsName) {
|
||
if (!children.length) return this._dEmpty(this._t('dashboard.empty_no_children'));
|
||
const amount = this.config.quick_points_amount || 5;
|
||
return html`${children.map((child, i) => {
|
||
const tone = this._designTone(i);
|
||
return html`
|
||
<div class="row pd-item" style="--ac:${tone}">
|
||
${this._av(child.name, child.avatar, tone, 36)}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="pd-item-title">${child.name}</div>
|
||
<div class="muted pd-item-sub">${child.points} ${pointsName}</div>
|
||
</div>
|
||
<div class="pd-acts">
|
||
<button class="btn bad sm round"
|
||
title="${this._t('dashboard.btn_remove_points_title', { amount, pointsName })}"
|
||
@click="${() => this._handlePoints(child.id, -amount)}">−</button>
|
||
<button class="btn good sm round"
|
||
title="${this._t('dashboard.btn_add_points_title', { amount, pointsName })}"
|
||
@click="${() => this._handlePoints(child.id, amount)}">+</button>
|
||
</div>
|
||
</div>`;
|
||
})}`;
|
||
}
|
||
|
||
_renderOverview(children, chores, completions, pointsIcon, pointsName) {
|
||
if (!children.length) return html`<div class="empty-section"><ha-icon icon="mdi:account-group"></ha-icon><span>${this._t('dashboard.empty_no_children')}</span></div>`;
|
||
|
||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || {};
|
||
const todayDow = attrs.today_day_of_week ||
|
||
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
|
||
const availability = attrs.chore_availability || {};
|
||
|
||
const timedSessions = attrs.active_timed_sessions || [];
|
||
const childById = Object.fromEntries(children.map(c => [c.id, c]));
|
||
const choreById = Object.fromEntries(chores.map(c => [c.id, c]));
|
||
|
||
return html`
|
||
${timedSessions.length > 0 ? html`
|
||
<div class="timed-sessions-section">
|
||
${timedSessions.map(s => {
|
||
const sChild = childById[s.child_id];
|
||
const sChore = choreById[s.chore_id];
|
||
if (!sChild || !sChore) return '';
|
||
let elapsed = s.total_seconds_today || 0;
|
||
if (s.state === 'running' && s.current_segment_start) {
|
||
elapsed += Math.max(0, Math.floor((Date.now() - new Date(s.current_segment_start).getTime()) / 1000));
|
||
}
|
||
const mins = Math.floor(elapsed / 60);
|
||
const secs = elapsed % 60;
|
||
return html`
|
||
<div class="timed-session-row">
|
||
<ha-icon icon="mdi:timer-outline" style="color: ${s.state === 'running' ? '#2ecc71' : '#e67e22'};"></ha-icon>
|
||
<span><strong>${sChild.name}</strong> — ${sChore.name} (${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')})</span>
|
||
${s.state === 'paused' ? html`<span class="paused-tag">${this._t('dashboard.paused_tag')}</span>` : ''}
|
||
</div>
|
||
`;
|
||
})}
|
||
</div>
|
||
` : ''}
|
||
${children.map(child => {
|
||
const childChores = chores.filter(c => {
|
||
// Skip disabled chores (one-shot completed or expired)
|
||
if (c.enabled === false) return false;
|
||
if ((c.disabled_for || []).includes(child.id)) return false;
|
||
|
||
const at = c.assigned_to || [];
|
||
const assigned = at.length === 0 || at.includes(child.id);
|
||
if (!assigned) return false;
|
||
const perChild = availability[c.id];
|
||
// One-shot: use is_available check (same as recurring)
|
||
if (c.schedule_mode === 'one_shot') {
|
||
if (perChild && perChild[child.id] === false) return false;
|
||
}
|
||
// Mode A: due days check
|
||
if (c.schedule_mode === 'specific_days') {
|
||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||
}
|
||
// Mode B: recurrence availability check
|
||
if (c.schedule_mode === 'recurring') {
|
||
if (perChild && perChild[child.id] === false) return false;
|
||
}
|
||
return true;
|
||
});
|
||
const childChoreIds = new Set(childChores.map(c => c.id));
|
||
const approved = completions.filter(c => c.child_id === child.id && c.approved && childChoreIds.has(c.chore_id)).length;
|
||
const total = childChores.length;
|
||
const pct = total > 0 ? Math.min(100, (approved / total) * 100) : 0;
|
||
const isComplete = total > 0 && approved >= total;
|
||
const cls = isComplete ? "complete" : pct > 0 ? "partial" : "none";
|
||
|
||
const isParent = window.__taskmate_is_parent(this.hass);
|
||
const outstanding = childChores.filter(c => {
|
||
const doneToday = completions.filter(
|
||
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
|
||
).length;
|
||
return doneToday < (c.daily_limit || 1);
|
||
});
|
||
const isOpen = !!this._expanded[child.id];
|
||
|
||
return html`
|
||
<div class="child-tile ${isParent ? 'tm-expandable' : ''}">
|
||
<div class="child-avatar">
|
||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||
</div>
|
||
<div class="child-tile-main"
|
||
@click="${isParent ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||
<div class="child-tile-header">
|
||
<span class="child-tile-name">${child.name}</span>
|
||
<span class="points-pill">
|
||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||
${child.points}
|
||
</span>
|
||
</div>
|
||
<div class="progress-row">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill ${cls}" style="width: ${pct}%"></div>
|
||
</div>
|
||
<span class="progress-label">${approved}/${total}</span>
|
||
</div>
|
||
${isParent && isOpen ? html`
|
||
<div class="tm-outstanding" @click="${(e) => e.stopPropagation()}">
|
||
<div class="tm-outstanding-hdr">${this._t('common.complete_on_behalf_heading')}</div>
|
||
${outstanding.length === 0 ? html`
|
||
<div class="tm-all-done">${this._t('common.complete_on_behalf_all_done')}</div>
|
||
` : outstanding.map(c => {
|
||
const key = `behalf_${child.id}_${c.id}`;
|
||
const loading = !!this._loading[key];
|
||
return html`
|
||
<div class="tm-outstanding-row">
|
||
<span class="tm-outstanding-name">${c.name}</span>
|
||
<span class="tm-outstanding-pts">
|
||
<ha-icon icon="${pointsIcon}" style="--mdc-icon-size:14px;"></ha-icon>${c.points}
|
||
</span>
|
||
<button class="btn-complete-behalf" ?disabled="${loading}"
|
||
title="${this._t('common.complete_on_behalf_tooltip', { name: child.name })}"
|
||
@click="${() => this._handleCompleteOnBehalf(c.id, child.id)}">
|
||
<ha-icon icon="mdi:check" style="--mdc-icon-size:16px;"></ha-icon>
|
||
${this._t('common.complete_on_behalf')}
|
||
</button>
|
||
</div>
|
||
`;
|
||
})}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
`;
|
||
}
|
||
|
||
_renderRotation(rotationChores, children, attrs) {
|
||
if (!rotationChores.length) {
|
||
return html`
|
||
<div class="empty-section">
|
||
<ha-icon icon="mdi:rotate-3d-variant"></ha-icon>
|
||
<span>${this._t('dashboard.empty_rotation', {}, 'No rotating chores configured')}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
const childById = Object.fromEntries((children || []).map(c => [c.id, c]));
|
||
const groups = attrs.task_groups || [];
|
||
const groupByChoreId = {};
|
||
for (const g of groups) {
|
||
for (const cid of (g.chore_ids || [])) {
|
||
groupByChoreId[cid] = g;
|
||
}
|
||
}
|
||
const unknown = this._t('dashboard.rotation_unassigned', {}, 'Nobody');
|
||
return html`
|
||
${rotationChores.map(chore => {
|
||
const currentChildId = chore.assignment_current_child_id || '';
|
||
const currentChild = childById[currentChildId];
|
||
const pool = (chore.assigned_to && chore.assigned_to.length) ? chore.assigned_to : (children || []).map(c => c.id);
|
||
const poolSize = pool.length;
|
||
const isSticky = groupByChoreId[chore.id] && groupByChoreId[chore.id].policy === 'sticky';
|
||
const isStickyFollower = isSticky && groupByChoreId[chore.id].chore_ids[0] !== chore.id;
|
||
const skipDisabled = poolSize <= 1 || isStickyFollower;
|
||
const group = groupByChoreId[chore.id];
|
||
const key = `skip_${chore.id}`;
|
||
const loading = !!this._loading[key];
|
||
return html`
|
||
<div class="approval-item ${loading ? 'loading' : ''}">
|
||
<div class="approval-child-avatar" style="background: linear-gradient(135deg, #16a085, #1abc9c);">
|
||
<ha-icon icon="${currentChild?.avatar || 'mdi:rotate-3d-variant'}"></ha-icon>
|
||
</div>
|
||
<div class="approval-info">
|
||
<span class="approval-chore">${chore.name}</span>
|
||
<span class="approval-meta">
|
||
<ha-icon icon="mdi:account" style="--mdc-icon-size: 14px;"></ha-icon>
|
||
${currentChild?.name || unknown}
|
||
· <span>${chore.assignment_mode}</span>
|
||
${group ? html`· <span>${group.policy === 'sticky' ? '🔗' : '🔀'} ${group.name}</span>` : ''}
|
||
</span>
|
||
</div>
|
||
<div class="approval-actions">
|
||
<button
|
||
class="btn-reject"
|
||
title="${this._t('dashboard.rotation_skip_hint', {}, 'Skip current child and move to the next in rotation (today only)')}"
|
||
?disabled="${skipDisabled}"
|
||
style="${skipDisabled ? 'opacity:0.4; cursor: not-allowed;' : ''}"
|
||
@click="${() => this._handleSkip(chore.id)}"
|
||
>
|
||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
`;
|
||
}
|
||
|
||
_renderApprovals(pending, children, chores, pointsIcon) {
|
||
if (!pending.length) return html`
|
||
<div class="empty-section">
|
||
<ha-icon icon="mdi:check-all" style="color: #2ecc71;"></ha-icon>
|
||
<span>${this._t('dashboard.empty_approvals')}</span>
|
||
</div>
|
||
`;
|
||
|
||
const childMap = {};
|
||
children.forEach(c => { childMap[c.id] = c; });
|
||
const choreMap = {};
|
||
chores.forEach(c => { choreMap[c.id] = c; });
|
||
|
||
return html`
|
||
${pending.map(comp => {
|
||
const child = childMap[comp.child_id];
|
||
const chore = choreMap[comp.chore_id];
|
||
const isLoading = this._loading[comp.completion_id];
|
||
const time = new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
|
||
return html`
|
||
<div class="approval-item ${isLoading ? 'loading' : ''}">
|
||
<div class="approval-child-avatar">
|
||
<ha-icon icon="${child?.avatar || 'mdi:account-circle'}"></ha-icon>
|
||
</div>
|
||
<div class="approval-info">
|
||
<div class="approval-chore">${comp.chore_name || chore?.name || this._t('common.unknown')}</div>
|
||
<div class="approval-meta">
|
||
<span>${child?.name || this._t('common.unknown')}</span>
|
||
<span>•</span>
|
||
<span>${time}</span>
|
||
<span class="approval-points">
|
||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||
+${comp.points || chore?.points || 0}
|
||
</span>
|
||
</div>
|
||
${tmSafePhotoUrl(comp.photo_url) ? html`
|
||
<a class="approval-photo" href="${tmSafePhotoUrl(comp.photo_url)}" target="_blank" rel="noopener"
|
||
@click="${(e) => this._openPhoto(e, tmSafePhotoUrl(comp.photo_url), this._photoCaption(comp.chore_name || chore?.name, child && child.name, comp.completed_at))}"
|
||
title="${this._t('approvals.view_photo') || 'View photo'}">
|
||
<img src="${tmSafePhotoUrl(comp.photo_url)}" alt="" loading="lazy">
|
||
</a>
|
||
` : ''}
|
||
</div>
|
||
<div class="approval-actions">
|
||
<button class="btn-approve" @click="${() => this._handleApprove(comp.completion_id)}" title="${this._t('common.approve')}" aria-label="${this._t('common.approve')}">
|
||
<ha-icon icon="mdi:check-bold"></ha-icon>
|
||
</button>
|
||
<button class="btn-reject" @click="${() => this._handleReject(comp.completion_id)}" title="${this._t('common.reject')}" aria-label="${this._t('common.reject')}">
|
||
<ha-icon icon="mdi:close-thick"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
`;
|
||
}
|
||
|
||
_renderClaims(claims, pointsIcon) {
|
||
if (!claims.length) return html`
|
||
<div class="empty-section">
|
||
<ha-icon icon="mdi:gift-outline" style="color: #9b59b6;"></ha-icon>
|
||
<span>${this._t('dashboard.empty_claims')}</span>
|
||
</div>
|
||
`;
|
||
|
||
return html`
|
||
${claims.map(claim => {
|
||
const isLoading = this._loading[claim.claim_id];
|
||
const time = new Date(claim.claimed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
return html`
|
||
<div class="claim-item ${isLoading ? 'loading' : ''}">
|
||
<div class="claim-icon-wrap">
|
||
<ha-icon icon="${claim.reward_icon || 'mdi:gift'}"></ha-icon>
|
||
</div>
|
||
<div class="claim-info">
|
||
<div class="claim-reward-name">${claim.reward_name}</div>
|
||
<div class="claim-meta">
|
||
${claim.child_name} • ${time} •
|
||
<span style="display:inline-flex;align-items:center;gap:2px;color:#e67e22;font-weight:600;">
|
||
<ha-icon icon="${pointsIcon}" style="--mdc-icon-size:11px;color:#f1c40f;"></ha-icon>
|
||
${claim.cost}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="approval-actions">
|
||
<button class="btn-approve" @click="${() => this._handleApproveReward(claim.claim_id)}" title="${this._t('common.approve')}" aria-label="${this._t('common.approve')}">
|
||
<ha-icon icon="mdi:check-bold"></ha-icon>
|
||
</button>
|
||
<button class="btn-reject" @click="${() => this._handleRejectReward(claim.claim_id)}" title="${this._t('common.reject')}" aria-label="${this._t('common.reject')}">
|
||
<ha-icon icon="mdi:close-thick"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
`;
|
||
}
|
||
|
||
_renderPoints(children, pointsIcon, pointsName) {
|
||
const amount = this.config.quick_points_amount || 5;
|
||
|
||
return html`
|
||
${children.map(child => html`
|
||
<div class="quick-points-row">
|
||
<div class="qp-avatar">
|
||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||
</div>
|
||
<span class="qp-name">${child.name}</span>
|
||
<span class="qp-points">
|
||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||
${child.points}
|
||
</span>
|
||
<div class="qp-actions">
|
||
<button class="btn-remove" @click="${() => this._handlePoints(child.id, -amount)}" title="${this._t('dashboard.btn_remove_points_title', { amount, pointsName })}" aria-label="${this._t('dashboard.btn_remove_points_title', { amount, pointsName })}">
|
||
<ha-icon icon="mdi:minus"></ha-icon>
|
||
</button>
|
||
<button class="btn-add" @click="${() => this._handlePoints(child.id, amount)}" title="${this._t('dashboard.btn_add_points_title', { amount, pointsName })}" aria-label="${this._t('dashboard.btn_add_points_title', { amount, pointsName })}">
|
||
<ha-icon icon="mdi:plus"></ha-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`)}
|
||
`;
|
||
}
|
||
|
||
_notifyServiceError(service, error, notificationId) {
|
||
console.error(`Failed to call ${service}:`, error);
|
||
if (this.hass && this.hass.callService) {
|
||
this.hass.callService("persistent_notification", "create", {
|
||
title: this._t('approvals.error_title'),
|
||
message: this._t('approvals.error_failed_service', {
|
||
service: service.replace(/_/g, " "),
|
||
message: error && error.message ? error.message : String(error),
|
||
}),
|
||
notification_id: notificationId,
|
||
});
|
||
}
|
||
}
|
||
|
||
async _handleApprove(completionId) {
|
||
if (this._loading[completionId]) return;
|
||
this._loading = { ...this._loading, [completionId]: true };
|
||
this.requestUpdate();
|
||
try {
|
||
await this.hass.callService("taskmate", "approve_chore", { completion_id: completionId });
|
||
} catch (e) {
|
||
this._notifyServiceError("approve_chore", e, `taskmate_dashboard_approve_${completionId}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [completionId]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async _handleReject(completionId) {
|
||
if (this._loading[completionId]) return;
|
||
this._loading = { ...this._loading, [completionId]: true };
|
||
this.requestUpdate();
|
||
try {
|
||
await this.hass.callService("taskmate", "reject_chore", { completion_id: completionId });
|
||
} catch (e) {
|
||
this._notifyServiceError("reject_chore", e, `taskmate_dashboard_reject_${completionId}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [completionId]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async _handleApproveReward(claimId) {
|
||
if (this._loading[claimId]) return;
|
||
this._loading = { ...this._loading, [claimId]: true };
|
||
this.requestUpdate();
|
||
try {
|
||
await this.hass.callService("taskmate", "approve_reward", { claim_id: claimId });
|
||
} catch (e) {
|
||
this._notifyServiceError("approve_reward", e, `taskmate_dashboard_approve_reward_${claimId}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [claimId]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async _handleRejectReward(claimId) {
|
||
if (this._loading[claimId]) return;
|
||
this._loading = { ...this._loading, [claimId]: true };
|
||
this.requestUpdate();
|
||
try {
|
||
await this.hass.callService("taskmate", "reject_reward", { claim_id: claimId });
|
||
} catch (e) {
|
||
this._notifyServiceError("reject_reward", e, `taskmate_dashboard_reject_reward_${claimId}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [claimId]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async _handleSkip(choreId) {
|
||
const key = `skip_${choreId}`;
|
||
this._loading = { ...this._loading, [key]: true };
|
||
this.requestUpdate();
|
||
try {
|
||
await this.hass.callService("taskmate", "skip_chore", { chore_id: choreId });
|
||
} catch (e) {
|
||
this._notifyServiceError("skip_chore", e, `taskmate_dashboard_skip_${choreId}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [key]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async _handlePoints(childId, delta) {
|
||
const key = `${childId}_${delta}`;
|
||
this._loading = { ...this._loading, [key]: true };
|
||
this.requestUpdate();
|
||
const service = delta > 0 ? "add_points" : "remove_points";
|
||
try {
|
||
await this.hass.callService("taskmate", service, {
|
||
child_id: childId,
|
||
points: Math.abs(delta),
|
||
});
|
||
} catch (e) {
|
||
this._notifyServiceError(service, e, `taskmate_dashboard_points_${key}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [key]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async _handleCompleteOnBehalf(choreId, childId) {
|
||
const key = `behalf_${childId}_${choreId}`;
|
||
if (this._loading[key]) return;
|
||
this._loading = { ...this._loading, [key]: true };
|
||
this.requestUpdate();
|
||
try {
|
||
await this.hass.callService("taskmate", "complete_chore", {
|
||
chore_id: choreId,
|
||
child_id: childId,
|
||
as_parent: true,
|
||
});
|
||
} catch (e) {
|
||
this._notifyServiceError("complete_chore", e, `taskmate_dashboard_behalf_${key}`);
|
||
} finally {
|
||
this._loading = { ...this._loading, [key]: false };
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
}
|
||
|
||
class TaskMateParentDashboardCardEditor extends LitElement {
|
||
static get properties() {
|
||
return { hass: { type: Object }, config: { type: Object } };
|
||
}
|
||
|
||
static get styles() {
|
||
return css`
|
||
:host { display: block; }
|
||
ha-form { display: block; margin-bottom: 16px; }
|
||
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
|
||
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
|
||
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
|
||
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
|
||
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
|
||
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
|
||
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
|
||
.preset-swatch:hover { transform: scale(1.15); }
|
||
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
|
||
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
|
||
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
|
||
`;
|
||
}
|
||
|
||
setConfig(config) { this.config = config; }
|
||
|
||
_t(key, params) {
|
||
const fn = window.__taskmate_localize;
|
||
return fn ? fn(this.hass, key, params) : key;
|
||
}
|
||
|
||
_buildSchema() {
|
||
return [
|
||
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
|
||
{ name: 'title', selector: { text: {} } },
|
||
{
|
||
name: 'card_design',
|
||
selector: {
|
||
select: {
|
||
options: window.__taskmate_design
|
||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||
: [{ value: 'global', label: 'Use global default' }],
|
||
mode: 'dropdown',
|
||
},
|
||
},
|
||
},
|
||
{ name: 'quick_points_amount', selector: { number: { min: 1, max: 100, mode: 'box' } } },
|
||
{ name: 'show_claims', selector: { boolean: {} } },
|
||
];
|
||
}
|
||
|
||
_computeLabel = (entry) => {
|
||
const labels = {
|
||
entity: this._t('dashboard.editor.entity_label'),
|
||
title: this._t('dashboard.editor.title_label'),
|
||
card_design: this._t('common.design.field_label'),
|
||
quick_points_amount: this._t('dashboard.editor.quick_points_label'),
|
||
show_claims: this._t('dashboard.editor.show_claims'),
|
||
};
|
||
return labels[entry.name] ?? entry.name;
|
||
};
|
||
|
||
_computeHelper = (entry) => {
|
||
const helpers = {
|
||
entity: this._t('dashboard.editor.entity_helper'),
|
||
quick_points_amount: this._t('dashboard.editor.quick_points_helper'),
|
||
};
|
||
return helpers[entry.name] ?? '';
|
||
};
|
||
|
||
render() {
|
||
if (!this.hass || !this.config) return html``;
|
||
const data = {
|
||
entity: this.config.entity || '',
|
||
title: this.config.title || '',
|
||
card_design: this.config.card_design || 'global',
|
||
quick_points_amount: this.config.quick_points_amount || 5,
|
||
show_claims: this.config.show_claims !== false,
|
||
};
|
||
return html`
|
||
<ha-form
|
||
.hass=${this.hass}
|
||
.data=${data}
|
||
.schema=${this._buildSchema()}
|
||
.computeLabel=${this._computeLabel}
|
||
.computeHelper=${this._computeHelper}
|
||
@value-changed=${this._formChanged}
|
||
></ha-form>
|
||
${this._renderColourPicker('header_color', '#c0392b')}
|
||
`;
|
||
}
|
||
|
||
_renderColourPicker(key, defaultValue) {
|
||
const d = window.__taskmate_design;
|
||
const current = this.config[key] || defaultValue;
|
||
if (!d || !d.colourPicker) return html``;
|
||
return d.colourPicker({
|
||
defaultValue, current,
|
||
label: this._t('common.editor.header_colour'),
|
||
helper: this._t('common.editor.header_colour_helper'),
|
||
resetLabel: this._t('common.reset'),
|
||
onInput: (v) => this._update(key, v),
|
||
onPreset: (v) => this._update(key, v),
|
||
onReset: () => this._update(key, defaultValue),
|
||
});
|
||
}
|
||
|
||
_formChanged(e) {
|
||
const newValues = e.detail.value || {};
|
||
const newConfig = { ...this.config };
|
||
for (const [key, value] of Object.entries(newValues)) {
|
||
if (value === '' || value === null || value === undefined) {
|
||
delete newConfig[key];
|
||
} else if (key === 'card_design' && value === 'global') {
|
||
delete newConfig[key];
|
||
} else if (key === 'show_claims' && value === true) {
|
||
delete newConfig[key];
|
||
} else if (key === 'quick_points_amount' && value === 5) {
|
||
delete newConfig[key];
|
||
} else {
|
||
newConfig[key] = value;
|
||
}
|
||
}
|
||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||
}));
|
||
}
|
||
|
||
_update(key, value) {
|
||
const cfg = { ...this.config, [key]: value };
|
||
this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: cfg }, bubbles: true, composed: true }));
|
||
}
|
||
}
|
||
|
||
customElements.define("taskmate-parent-dashboard-card", TaskMateParentDashboardCard);
|
||
customElements.define("taskmate-parent-dashboard-card-editor", TaskMateParentDashboardCardEditor);
|
||
|
||
window.customCards = window.customCards || [];
|
||
window.customCards.push({
|
||
type: "taskmate-parent-dashboard-card",
|
||
name: "TaskMate Parent Dashboard",
|
||
description: "Unified parent view with approvals, child progress, and quick point controls",
|
||
preview: true,
|
||
getEntitySuggestion: (hass, entityId) =>
|
||
window.__taskmate_suggest(hass, entityId, "taskmate-parent-dashboard-card", "overview"),
|
||
});
|
||
|
||
// Version is injected by the HA resource URL (?v=x.x.x) and read from the DOM
|
||
const _tmVersion = new URLSearchParams(
|
||
Array.from(document.querySelectorAll('script[src*="/taskmate-parent-dashboard-card.js"]'))
|
||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||
).get("v") || "?";
|
||
console.info(
|
||
"%c TASKMATE PARENT DASHBOARD CARD %c v" + _tmVersion + " ",
|
||
"background:#c0392b;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
|
||
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
|
||
);
|