Reverse #278's deliberate no-collapse decision for the budget grid: it now folds/unfolds at every category level like the hierarchical reports, opening fully collapsed with a one-click "Expand all". - BudgetTable: wire useCollapsibleGroups (defaultExpanded: false), inline BUDGET_COLLAPSE_ACCESSORS + BUDGET_EXPANDED_KEY (mirrors ComparePeriodTable / BudgetVsActualTable — no budgetTableModel.ts). Chevron + aria-expanded + aria-level on every parent row; groups.visible(group) before reorderRows. - BudgetTable: section subtotal now uses the tested sumLeavesForType on the RAW group (drop-in for the hand-rolled loop) so folding stays purely visual. - BudgetTable: rename STORAGE_KEY to "budget-subtotals-position", decoupling the subtotals-position preference from BudgetVsActualTable (they collided). - useBudget: extract the pure buildBudgetYearRows(); the grid's rows are level-order (BFS), not DFS — document the invariant and pin it in a test, since the #288 ancestor-walk collapse is order-independent (the v1 plan assumed DFS and would have broken here). - Tests: useBudget.test.ts locks the level-order emission, the DFS-killer, the end-to-end multi-level collapse on real builder output, and that subtotals sum raw rows regardless of collapse. 828 vitest pass. Resolves #289 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
458 lines
20 KiB
TypeScript
458 lines
20 KiB
TypeScript
import { useState, useRef, useEffect, Fragment } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||
import type { BudgetYearRow } from "../../shared/types";
|
||
import { reorderRows } from "../../utils/reorderRows";
|
||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
|
||
|
||
const fmt = new Intl.NumberFormat("en-CA", {
|
||
style: "currency",
|
||
currency: "CAD",
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: 0,
|
||
});
|
||
|
||
const MONTH_KEYS = [
|
||
"months.jan", "months.feb", "months.mar", "months.apr",
|
||
"months.may", "months.jun", "months.jul", "months.aug",
|
||
"months.sep", "months.oct", "months.nov", "months.dec",
|
||
] as const;
|
||
|
||
// Prefixed so the "subtotals on top/bottom" preference is INDEPENDENT of the
|
||
// real-vs-budget report's (BudgetVsActualTable still uses "subtotals-position") —
|
||
// they collided before (issue #289).
|
||
const STORAGE_KEY = "budget-subtotals-position";
|
||
|
||
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
||
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
||
// Declared inline, mirroring ComparePeriodTable / BudgetVsActualTable (issue #289).
|
||
const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
|
||
keyOf: (row) => `p:${row.category_id}`,
|
||
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
||
depthOf: (row) => row.depth ?? 0,
|
||
isParent: (row) => row.is_parent,
|
||
};
|
||
|
||
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
|
||
|
||
interface BudgetTableProps {
|
||
rows: BudgetYearRow[];
|
||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
|
||
}
|
||
|
||
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
|
||
const { t } = useTranslation();
|
||
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
||
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
||
const [editingValue, setEditingValue] = useState("");
|
||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||
const stored = localStorage.getItem(STORAGE_KEY);
|
||
return stored === null ? true : stored === "top";
|
||
});
|
||
|
||
const toggleSubtotals = () => {
|
||
setSubtotalsOnTop((prev) => {
|
||
const next = !prev;
|
||
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Collapse/expand at every hierarchy level — collapsed by default (issue #289),
|
||
// matching the hierarchical reports. Persisted per profile in user_preferences.
|
||
const groups = useCollapsibleGroups<BudgetYearRow>(
|
||
BUDGET_EXPANDED_KEY,
|
||
BUDGET_COLLAPSE_ACCESSORS,
|
||
{ defaultExpanded: false },
|
||
);
|
||
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const annualInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (editingCell && inputRef.current) {
|
||
inputRef.current.focus();
|
||
inputRef.current.select();
|
||
}
|
||
}, [editingCell]);
|
||
|
||
useEffect(() => {
|
||
if (editingAnnual && annualInputRef.current) {
|
||
annualInputRef.current.focus();
|
||
annualInputRef.current.select();
|
||
}
|
||
}, [editingAnnual]);
|
||
|
||
const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => {
|
||
setEditingAnnual(null);
|
||
setEditingCell({ categoryId, monthIdx });
|
||
setEditingValue(currentValue === 0 ? "" : String(currentValue));
|
||
};
|
||
|
||
const handleStartEditAnnual = (categoryId: number, currentValue: number) => {
|
||
setEditingCell(null);
|
||
setEditingAnnual({ categoryId });
|
||
setEditingValue(currentValue === 0 ? "" : String(currentValue));
|
||
};
|
||
|
||
const handleSave = () => {
|
||
if (!editingCell) return;
|
||
const amount = parseFloat(editingValue) || 0;
|
||
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
|
||
setEditingCell(null);
|
||
};
|
||
|
||
const handleSaveAnnual = () => {
|
||
if (!editingAnnual) return;
|
||
const amount = parseFloat(editingValue) || 0;
|
||
onSplitEvenly(editingAnnual.categoryId, amount);
|
||
setEditingAnnual(null);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setEditingCell(null);
|
||
setEditingAnnual(null);
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") handleSave();
|
||
if (e.key === "Escape") handleCancel();
|
||
if (e.key === "Tab") {
|
||
e.preventDefault();
|
||
if (!editingCell) return;
|
||
const amount = parseFloat(editingValue) || 0;
|
||
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
|
||
// Move to next month cell
|
||
const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1);
|
||
if (nextMonth >= 0 && nextMonth < 12) {
|
||
const row = rows.find((r) => r.category_id === editingCell.categoryId && !r.is_parent);
|
||
if (row) {
|
||
handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]);
|
||
}
|
||
} else {
|
||
setEditingCell(null);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleAnnualKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") handleSaveAnnual();
|
||
if (e.key === "Escape") handleCancel();
|
||
};
|
||
|
||
// Sign multiplier: expenses negative, income/transfer positive
|
||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||
|
||
// Group rows by type
|
||
const grouped: Record<string, BudgetYearRow[]> = {};
|
||
for (const row of rows) {
|
||
const key = row.category_type;
|
||
if (!grouped[key]) grouped[key] = [];
|
||
grouped[key].push(row);
|
||
}
|
||
|
||
// Income-statement reading order: revenue first, then expenses, then
|
||
// transfers (Issue #278) — matches the compare/trend reports' ordering.
|
||
const typeOrder = ["income", "expense", "transfer"] as const;
|
||
const typeLabelKeys: Record<string, string> = {
|
||
expense: "budget.expenses",
|
||
income: "budget.income",
|
||
transfer: "budget.transfers",
|
||
};
|
||
const typeTotalKeys: Record<string, string> = {
|
||
expense: "budget.totalExpenses",
|
||
income: "budget.totalIncome",
|
||
transfer: "budget.totalTransfers",
|
||
};
|
||
|
||
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
|
||
// computed on the budgeted + previous-year-actual leaves. Mathematically
|
||
// equivalent to the old plain grand-total (income − expense + transfer over
|
||
// every leaf), now surfaced as the two labeled result rows below instead of
|
||
// a single unlabeled "Total".
|
||
const results = computeBudgetResults(rows);
|
||
|
||
const totalCols = 15; // category + prev year + annual + 12 months
|
||
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||
<p>{t("budget.noCategories")}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const formatSigned = (value: number) => {
|
||
if (value === 0) return <span className="text-[var(--muted-foreground)]">—</span>;
|
||
const color = value > 0 ? "text-[var(--positive)]" : "text-[var(--negative)]";
|
||
return <span className={color}>{fmt.format(value)}</span>;
|
||
};
|
||
|
||
const renderRow = (row: BudgetYearRow) => {
|
||
const sign = signFor(row.category_type);
|
||
const isChild = row.parent_id !== null && !row.is_parent;
|
||
const depth = row.depth ?? (isChild ? 1 : 0);
|
||
// Unique key: parent rows and "(direct)" fake children can share the same category_id
|
||
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
|
||
|
||
if (row.is_parent) {
|
||
// Parent subtotal row: read-only, bold, distinct background. Collapsible
|
||
// at every level (issue #289) — a chevron toggles its subtree.
|
||
const parentDepth = row.depth ?? 0;
|
||
const isTopParent = parentDepth === 0;
|
||
const isIntermediateParent = parentDepth >= 1;
|
||
const collapsed = groups.isCollapsed(row);
|
||
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
|
||
return (
|
||
<tr
|
||
key={rowKey}
|
||
aria-level={parentDepth + 1}
|
||
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
||
>
|
||
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => groups.toggle(row)}
|
||
aria-expanded={!collapsed}
|
||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||
>
|
||
{collapsed ? (
|
||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||
) : (
|
||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||
)}
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||
style={{ backgroundColor: row.category_color }}
|
||
/>
|
||
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
||
</button>
|
||
</td>
|
||
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
|
||
{formatSigned(row.previousYearTotal)}
|
||
</td>
|
||
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
||
{formatSigned(row.annual * sign)}
|
||
</td>
|
||
{row.months.map((val, mIdx) => (
|
||
<td key={mIdx} className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
||
{formatSigned(val * sign)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// Leaf / child row: editable
|
||
return (
|
||
<tr
|
||
key={rowKey}
|
||
aria-level={depth + 1}
|
||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
||
>
|
||
{/* Category name - sticky */}
|
||
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||
style={{ backgroundColor: row.category_color }}
|
||
/>
|
||
<span className="truncate text-xs">{row.category_name}</span>
|
||
</div>
|
||
</td>
|
||
{/* Previous year total — read-only */}
|
||
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
|
||
<span className="text-xs px-1 py-0.5">
|
||
{formatSigned(row.previousYearTotal)}
|
||
</span>
|
||
</td>
|
||
{/* Annual total — editable */}
|
||
<td className="py-2 px-2 text-right">
|
||
{editingAnnual?.categoryId === row.category_id ? (
|
||
<input
|
||
ref={annualInputRef}
|
||
type="number"
|
||
step="0.01"
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onBlur={handleSaveAnnual}
|
||
onKeyDown={handleAnnualKeyDown}
|
||
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-end gap-1">
|
||
<button
|
||
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
||
title={t("budget.clickToEdit")}
|
||
className="font-medium text-xs hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer rounded px-1 py-0.5"
|
||
>
|
||
{formatSigned(row.annual * sign)}
|
||
</button>
|
||
{(() => {
|
||
const monthSum = row.months.reduce((s, v) => s + v, 0);
|
||
return row.annual !== 0 && Math.abs(row.annual - monthSum) > 0.01 ? (
|
||
<span title={t("budget.annualMismatch")} className="text-[var(--negative)]">
|
||
<AlertTriangle size={13} />
|
||
</span>
|
||
) : null;
|
||
})()}
|
||
</div>
|
||
)}
|
||
</td>
|
||
{/* 12 month cells */}
|
||
{row.months.map((val, mIdx) => (
|
||
<td key={mIdx} className="py-2 px-2 text-right">
|
||
{editingCell?.categoryId === row.category_id && editingCell.monthIdx === mIdx ? (
|
||
<input
|
||
ref={inputRef}
|
||
type="number"
|
||
step="0.01"
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onBlur={handleSave}
|
||
onKeyDown={handleKeyDown}
|
||
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||
/>
|
||
) : (
|
||
<button
|
||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||
title={t("budget.clickToEdit")}
|
||
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
|
||
>
|
||
{formatSigned(val * sign)}
|
||
</button>
|
||
)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
};
|
||
|
||
// One type's section: header, its (reorderable) rows, and a leaf-summed
|
||
// subtotal row. Extracted so it can be called for income/expense, then
|
||
// again for transfer once the Résultat rows are interleaved between them.
|
||
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
|
||
const group = grouped[type];
|
||
if (!group || group.length === 0) return null;
|
||
// Section subtotal is summed from the RAW group via the tested
|
||
// `sumLeavesForType` (leaves only, sign applied to budgeted figures), never
|
||
// from the collapse-filtered rows — folding a parent stays purely visual and
|
||
// never moves a total (issue #289).
|
||
const sectionTotals = sumLeavesForType(group, type);
|
||
return (
|
||
<Fragment key={type}>
|
||
<tr>
|
||
<td
|
||
colSpan={totalCols}
|
||
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
|
||
>
|
||
{t(typeLabelKeys[type])}
|
||
</td>
|
||
</tr>
|
||
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
|
||
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
||
{t(typeTotalKeys[type])}
|
||
</td>
|
||
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionTotals.previousYearTotal)}</td>
|
||
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionTotals.annual)}</td>
|
||
{sectionTotals.months.map((total, mIdx) => (
|
||
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
||
{formatSigned(total)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
</Fragment>
|
||
);
|
||
};
|
||
|
||
// A Résultat row (avant-transferts subtotal, or the net bottom line).
|
||
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
|
||
// the softer variant mirrors the per-type section-subtotal row above.
|
||
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
|
||
const rowClass = strong
|
||
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
|
||
: "bg-[var(--muted)]/40 border-b border-[var(--border)]";
|
||
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
|
||
const cellWeight = strong ? "" : "font-semibold";
|
||
const pad = strong ? "py-3" : "py-2.5";
|
||
return (
|
||
<tr key={labelKey} className={rowClass}>
|
||
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
|
||
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
|
||
{formatSigned(tot.previousYearTotal)}
|
||
</td>
|
||
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
|
||
{tot.months.map((val, mIdx) => (
|
||
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
|
||
{formatSigned(val)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
};
|
||
|
||
const hasGroups = groups.groupCount(rows) > 0;
|
||
const allExpanded = groups.allExpanded(rows);
|
||
|
||
return (
|
||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||
{hasGroups && (
|
||
<button
|
||
type="button"
|
||
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||
>
|
||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={toggleSubtotals}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||
>
|
||
<ArrowUpDown size={13} />
|
||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||
</button>
|
||
</div>
|
||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||
<table className="w-full text-sm whitespace-nowrap">
|
||
<thead className="sticky top-0 z-20">
|
||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
||
{t("budget.category")}
|
||
</th>
|
||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||
{t("budget.previousYear")}
|
||
</th>
|
||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||
{t("budget.annual")}
|
||
</th>
|
||
{MONTH_KEYS.map((key) => (
|
||
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
|
||
{t(key)}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{renderTypeSection("income")}
|
||
{renderTypeSection("expense")}
|
||
{/* Operating result (revenues − expenses), interleaved before the
|
||
transfers section only when transfers exist — otherwise it
|
||
equals the net result below and would just be noise. */}
|
||
{results.hasTransfers &&
|
||
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||
{renderTypeSection("transfer")}
|
||
{/* Bottom line: result after netting transfers. */}
|
||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|