Hello from MCP server
<template>
<div class="framework-view">
<div class="top-row">
<div class="top-row-left">
<button type="button" @click="handleDirectoryClick">Directory</button>
<button type="button" @click="handleCalculatePriceClick">Calculate Price</button>
<button type="button" @click="handlePriceListClick">Price List</button>
<button type="button" @click="handleJobContentClick">Job Content</button>
<button type="button" @click="handleChangesClick">Changes</button>
<button type="button" @click="handleCurrenciesClick">Currencies</button>
<button type="button" @click="handleLogsClick">Logs</button>
</div>
<div class="top-row-right">
<button type="button" @click="handleClear">Clear</button>
<select v-model="selectedCurrency" class="currency-select">
<option v-if="!currenciesData" value="">Loading...</option>
<option v-for="currency in currenciesData?.currencies || []" :key="currency.id" :value="currency.refId">
{{ currency.symbol }} {{ currency.refId }}
</option>
</select>
<button type="button" @click="handleRelogin">Re-login</button>
</div>
</div>
<div class="second-row">
<div class="second-row-right">
<input
v-model="editId"
type="text"
placeholder="Record ID"
class="edit-input"
@keydown.enter="handleEditNavigate"
/>
<button type="button" @click="handleEditNavigate">Edit</button>
</div>
</div>
<div v-if="showCalculatePrice" class="calculate-price-row">
<select v-model="selectedFormula" class="formula-select" @change="handleFormulaChange">
<option value="">-- Select a formula --</option>
<option value="tnfrLegacyWithHours">TNFR Legacy with Hours</option>
</select>
<button v-if="selectedFormula" type="button" @click="handleShowDocs">Documentation</button>
<button v-if="selectedFormula" type="button" @click="handleShowFormulaJson">Formula</button>
<button v-if="selectedFormula" type="button" @click="handleShowCalculator">Calculate</button>
</div>
<div v-if="showCalculatePrice && selectedFormula" class="calculate-price-row">
<select v-model="selectedOfferId" class="formula-select" @change="handleOfferSelect">
<option value="">-- Select an offer --</option>
<option v-for="offer in availableOffers" :key="offer.id" :value="offer.id">
{{ offer.refId || offer.name || offer.id }}
</option>
</select>
</div>
<div v-if="formulaDocs" class="formula-docs">
<pre>{{ formulaDocs }}</pre>
</div>
<div v-if="showFormulaJson && formulaJsonData" class="formula-json">
<div class="json-controls">
<button type="button" @click="expandAllJson">Expand All</button>
<button type="button" @click="collapseAllJson">Collapse All</button>
</div>
<pre><code><json-node :key="jsonKey" :data="formulaJsonData" :indent="0" :start-collapsed="!jsonExpandAll" :expand-all="jsonExpandMode" :on-copy="onCopyToEdit" /></code></pre>
</div>
<div v-if="showCalculator" class="calculator-view">
<div class="collapsible-section">
<div class="section-header" @click="inputSectionOpen = !inputSectionOpen">
<span class="collapse-icon">{{ inputSectionOpen ? '−' : '+' }}</span>
<span>Input</span>
</div>
<div v-if="inputSectionOpen" class="section-content">
<div v-for="(meta, key) in calculatorInputs" :key="key" class="input-row">
<label :for="'input-' + key">
{{ key }}
<span class="unit-label">({{ meta.unit }})</span>
</label>
<template v-if="meta.unit === 'currency'">
<span class="currency-symbol">{{ currentCurrencySymbol }}</span>
<input
:id="'input-' + key"
:value="calculatorDisplayInputs[key]"
type="number"
step="any"
@input="updateDisplayInput(key, $event)"
/>
</template>
<input
v-else-if="meta.unit !== 'scale'"
:id="'input-' + key"
v-model.number="calculatorInputs[key].value"
type="number"
step="any"
/>
<span v-else class="array-value">[scale]</span>
</div>
<button type="button" class="run-button" @click="runCalculation">Run Calculation</button>
</div>
</div>
<div class="collapsible-section">
<div class="section-header" @click="outputSectionOpen = !outputSectionOpen">
<span class="collapse-icon">{{ outputSectionOpen ? '−' : '+' }}</span>
<span>Output</span>
</div>
<div v-if="outputSectionOpen" class="section-content">
<div v-if="calculatorOutput !== null">
<div class="json-controls">
<button type="button" @click="expandAllJson">Expand All</button>
<button type="button" @click="collapseAllJson">Collapse All</button>
</div>
<pre><code><json-node :key="jsonKey" :data="calculatorOutput" :indent="0" :start-collapsed="!jsonExpandAll" :expand-all="jsonExpandMode" :on-copy="onCopyToEdit" /></code></pre>
</div>
<div v-else class="no-output">Run calculation to see output</div>
</div>
</div>
</div>
<div v-if="showSearch" class="search-container">
<input
v-model="searchQuery"
type="text"
placeholder="Search problems..."
@keydown.enter="handleSearch"
/>
<button type="button" @click="handleSearch">Search</button>
</div>
<div v-if="directoryData" class="filter-section">
<div class="filter-label">Categories:</div>
<div class="category-buttons">
<button
v-for="category in directoryData.categories"
:key="category.id"
type="button"
class="category-btn"
:class="{ selected: selectedCategory === category.id }"
@click="handleCategoryClick(category)"
>
{{ category.name }} ({{ getCategoryProblemCount(category.id) }})
</button>
</div>
</div>
<div v-if="directoryData && directoryData.tags.length" class="filter-section">
<div class="filter-label">Tags:</div>
<select v-model="selectedTagId" class="tag-select" @change="handleTagSelect">
<option value="">-- Select a tag --</option>
<option v-for="tag in directoryData.tags" :key="tag.id" :value="tag.id">
{{ tag.name }}
</option>
</select>
<div v-if="selectedTags.length" class="selected-tags">
<span v-for="tagId in selectedTags" :key="tagId" class="tag-chip">
{{ getTagName(tagId) }}
</span>
</div>
</div>
<div v-if="directoryData" class="results-info">
<template v-if="searchQuery">
{{ directoryData.searchResults.length }} search results
</template>
<template v-else>
{{ getTotalCategoryProblems() }} problems
<span v-if="selectedCategory"> in selected category</span>
</template>
</div>
<div v-if="directoryData" class="json-block">
<div class="json-controls">
<button type="button" @click="expandAllJson">Expand All</button>
<button type="button" @click="collapseAllJson">Collapse All</button>
</div>
<pre><code><json-node :key="jsonKey" :data="directoryData" :indent="0" :start-collapsed="!jsonExpandAll" :expand-all="jsonExpandMode" :on-copy="onCopyToEdit" /></code></pre>
</div>
<div v-if="priceListData" class="json-block">
<div class="json-controls">
<button type="button" @click="expandAllJson">Expand All</button>
<button type="button" @click="collapseAllJson">Collapse All</button>
</div>
<pre><code><json-node :key="jsonKey" :data="priceListData" :indent="0" :start-collapsed="!jsonExpandAll" :expand-all="jsonExpandMode" :on-copy="onCopyToEdit" /></code></pre>
</div>
<div v-if="showJobContent" class="job-content-section">
<div class="job-content-row">
<select v-model="selectedProblemId" class="problem-select">
<option value="">-- Select a problem --</option>
<option v-for="problem in availableProblems" :key="problem.id" :value="problem.id">
{{ problem.name || problem.refId }}
</option>
</select>
<button type="button" :disabled="!selectedProblemId" @click="handleGetContent">Get Content</button>
</div>
<div v-if="jobContentData" class="json-block">
<div class="json-controls">
<button type="button" @click="expandAllJson">Expand All</button>
<button type="button" @click="collapseAllJson">Collapse All</button>
</div>
<pre><code><json-node :key="jsonKey" :data="jobContentData" :indent="0" :start-collapsed="!jsonExpandAll" :expand-all="jsonExpandMode" :on-copy="onCopyToEdit" /></code></pre>
</div>
</div>
<div v-if="changesData || changesTab !== 'history'" class="changes-section">
<div class="changes-controls">
<button type="button" :class="{ active: changesTab === 'history' }" @click="changesTab = 'history'">History</button>
<button type="button" :class="{ active: changesTab === 'schemas' }" @click="changesTab = 'schemas'">Schemas</button>
<button type="button" :class="{ active: changesTab === 'create' }" @click="changesTab = 'create'">Create</button>
<button type="button" class="download-btn" :disabled="isDownloading" @click="handleDownloadChanges">
{{ isDownloading ? 'Downloading...' : 'Download from API' }}
</button>
<span v-if="downloadStatus" :class="['download-status', downloadStatus.success ? 'success' : 'error']">
{{ downloadStatus.message }}
</span>
</div>
<!-- History Tab -->
<template v-if="changesTab === 'history'">
<div class="results-info">{{ changesData?.total || 0 }} changes</div>
<div class="json-block">
<div class="json-controls">
<button type="button" @click="expandAllJson">Expand All</button>
<button type="button" @click="collapseAllJson">Collapse All</button>
</div>
<pre><code><json-node :key="jsonKey" :data="changesData" :indent="0" :start-collapsed="!jsonExpandAll" :expand-all="jsonExpandMode" :on-copy="onCopyToEdit" /></code></pre>
</div>
</template>
<!-- Schemas Tab -->
<template v-else-if="changesTab === 'schemas'">
<div class="results-info">{{ collectionSchemas.length }} collections</div>
<div class="schemas-list">
<div v-for="schema in collectionSchemas" :key="schema.name" class="schema-card">
<div class="schema-header">
<span class="schema-name">{{ schema.name }}</span>
<span class="schema-desc">{{ schema.description }}</span>
</div>
<table class="schema-fields">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr v-for="field in schema.fields" :key="field.name" :class="{ required: field.required }">
<td class="field-name">{{ field.name }}</td>
<td class="field-type">
{{ field.type }}
<span v-if="field.refCollection" class="ref-target">→ {{ field.refCollection }}</span>
</td>
<td class="field-required">{{ field.required ? 'Yes' : '' }}</td>
<td class="field-desc">{{ field.description }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- Create Tab -->
<template v-else-if="changesTab === 'create'">
<div class="create-form">
<div class="form-row">
<label>Collection</label>
<select v-model="createCollection" class="collection-select" @change="handleCollectionChange">
<option value="">-- Select collection --</option>
<option v-for="schema in collectionSchemas" :key="schema.name" :value="schema.name">
{{ schema.name }}
</option>
</select>
</div>
<template v-if="selectedSchema">
<div class="schema-info">{{ selectedSchema.description }}</div>
<div v-for="field in selectedSchema.fields" :key="field.name" class="form-row">
<label :class="{ required: field.required }">
{{ field.name }}
<span class="field-hint">{{ field.type }}<template v-if="field.refCollection"> → {{ field.refCollection }}</template></span>
</label>
<template v-if="field.type === 'ref'">
<input
v-model="createRefInputs[field.name]"
type="text"
:placeholder="`refId(s) for ${field.refCollection}, comma-separated`"
class="form-input"
/>
</template>
<template v-else-if="field.type === 'number'">
<div class="input-with-hint">
<input
v-model.number="createFormData[field.name]"
type="number"
step="any"
class="form-input"
:placeholder="createCollection === 'costsMaterial' && field.name === 'quantity' ? 'Enter in your currency' : ''"
/>
<span v-if="createCollection === 'costsMaterial' && field.name === 'quantity' && currencyPrefsData" class="currency-hint">
{{ currencyPrefsData.symbol }} → XAG
</span>
</div>
</template>
<template v-else>
<input
v-model="createFormData[field.name]"
type="text"
:placeholder="field.description"
class="form-input"
/>
</template>
</div>
<div class="form-actions">
<button type="button" @click="generateChangeRecord">Generate Change Record</button>
</div>
<div v-if="createdChangeRecord" class="created-record">
<div class="record-header">
<span>Generated Change Record</span>
<button type="button" @click="copyChangeRecord">Copy</button>
</div>
<pre><code>{{ JSON.stringify(createdChangeRecord, null, 2) }}</code></pre>
<div class="commit-section">
<select v-model="commitBookId" class="book-select">
<option value="">-- Select book --</option>
<option v-for="book in availableBooks" :key="book.id" :value="book.id">
{{ book.refId || book.name || book.id }}
</option>
</select>
<button type="button" class="commit-button" @click="commitChangeRecord" :disabled="!commitBookId">
Commit to Pocketbase
</button>
</div>
<div v-if="commitStatus" :class="['commit-status', commitStatus.success ? 'success' : 'error']">
{{ commitStatus.message }}
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Currencies Section -->
<div v-if="showCurrencies && currenciesData" class="currencies-section">
<div class="currencies-toolbar">
<span class="toolbar-label">convert()</span>
<input v-model.number="convertValue" type="number" step="any" placeholder="Value" class="convert-input" />
<select v-model="convertFrom" class="convert-select">
<option value="">From</option>
<option v-for="c in currenciesData.currencies" :key="c.id" :value="c.refId">{{ c.refId }}</option>
</select>
<span class="toolbar-arrow">→</span>
<select v-model="convertTo" class="convert-select">
<option value="">To</option>
<option v-for="c in currenciesData.currencies" :key="c.id" :value="c.refId">{{ c.refId }}</option>
</select>
<button type="button" @click="handleConvert" :disabled="!convertFrom || !convertTo">Convert</button>
<span v-if="convertResult !== null" class="convert-result">= {{ convertResult }}</span>
</div>
<div class="results-info">{{ currenciesData.currencies.length }} currencies, {{ currenciesData.rates.length }} rates</div>
<div class="currencies-grid">
<div class="currencies-card">
<h3>Currencies</h3>
<table class="currencies-table">
<thead>
<tr>
<th>RefId</th>
<th>Name</th>
<th>Symbol</th>
</tr>
</thead>
<tbody>
<tr v-for="c in currenciesData.currencies" :key="c.id">
<td class="mono">{{ c.refId }}</td>
<td>{{ c.name }}</td>
<td class="mono">{{ c.symbol }}</td>
</tr>
</tbody>
</table>
</div>
<div class="currencies-card">
<h3>Exchange Rates</h3>
<table class="currencies-table">
<thead>
<tr>
<th>Base</th>
<th>Quote</th>
<th>Rate</th>
<th v-if="currenciesData.rates[0]?.name">Name</th>
<th v-if="currenciesData.rates[0]?.created">Date</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, idx) in currenciesData.rates" :key="idx">
<td class="mono">{{ r.baseCurrency }}</td>
<td class="mono">{{ r.quoteCurrency }}</td>
<td class="mono">{{ r.rate }}</td>
<td v-if="r.name">{{ r.name }}</td>
<td v-if="r.created" class="mono">{{ r.created?.split('T')[0] }}</td>
</tr>
</tbody>
</table>
</div>
<div class="currencies-card">
<h3>Currency Prefs (userPrefs)</h3>
<div v-if="currencyPrefsData" class="prefs-list">
<div class="prefs-row">
<span class="prefs-label">baseCurrency</span>
<span class="prefs-value mono">{{ currencyPrefsData.baseCurrency }}</span>
</div>
<div class="prefs-row">
<span class="prefs-label">targetCurrency</span>
<span class="prefs-value mono">{{ currencyPrefsData.targetCurrency }}</span>
</div>
<div class="prefs-row">
<span class="prefs-label">exchangeRate</span>
<span class="prefs-value mono">{{ currencyPrefsData.exchangeRate }}</span>
</div>
<div class="prefs-row">
<span class="prefs-label">symbol</span>
<span class="prefs-value mono">{{ currencyPrefsData.symbol }}</span>
</div>
</div>
<div v-else class="no-prefs">No currency prefs saved</div>
</div>
</div>
</div>
<!-- Logs Section -->
<div v-if="showLogs" class="logs-section">
<div class="logs-controls">
<button type="button" :class="{ active: logsTab === 'list' }" @click="logsTab = 'list'">List</button>
<button type="button" :class="{ active: logsTab === 'create' }" @click="logsTab = 'create'">Create</button>
<button type="button" class="refresh-btn" @click="handleRefreshLogs">Refresh</button>
</div>
<!-- List Tab -->
<template v-if="logsTab === 'list'">
<div class="logs-filters">
<input v-model="logsFilterType" type="text" placeholder="Filter by log_type" class="filter-input" />
<input v-model="logsFilterCreatedBy" type="text" placeholder="Filter by created_by" class="filter-input" />
<button type="button" @click="handleRefreshLogs">Apply</button>
</div>
<div class="results-info">{{ logsData.length }} logs</div>
<table class="logs-table">
<thead>
<tr>
<th>ID</th>
<th>Created At</th>
<th>Type</th>
<th>Created By</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logsData" :key="log.id">
<td class="mono">{{ log.id }}</td>
<td class="mono">{{ log.created_at?.replace('T', ' ').substring(0, 19) }}</td>
<td class="mono">{{ log.log_type }}</td>
<td>{{ log.created_by_name }} <span class="created-by-id">({{ log.created_by }})</span></td>
<td class="log-data-cell">
<details>
<summary>{{ Object.keys(log.log_data || {}).length }} fields</summary>
<pre>{{ JSON.stringify(log.log_data, null, 2) }}</pre>
</details>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Create Tab -->
<template v-else-if="logsTab === 'create'">
<div class="create-form">
<div class="form-row">
<label class="required">log_type</label>
<input v-model="newLogType" type="text" placeholder="e.g., service_call_completed" class="form-input" />
</div>
<div class="form-row">
<label class="required">created_by</label>
<input v-model="newLogCreatedBy" type="text" placeholder="User ID or system ID" class="form-input" />
</div>
<div class="form-row">
<label class="required">created_by_name</label>
<input v-model="newLogCreatedByName" type="text" placeholder="Human-readable name" class="form-input" />
</div>
<div class="form-row">
<label class="required">log_data (JSON)</label>
<textarea v-model="newLogData" placeholder='{"key": "value"}' class="form-textarea"></textarea>
</div>
<div class="form-actions">
<button type="button" @click="handleSaveLog">Save Log</button>
</div>
<div v-if="logSaveStatus" :class="['commit-status', logSaveStatus.success ? 'success' : 'error']">
{{ logSaveStatus.message }}
</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import { ref, computed, defineComponent, onMounted } from "vue";
import { useRouter } from "vue-router";
import { loadDirectory, type DirectoryData, type DirectoryQuery } from "@/framework/directory";
import { loadPriceList, type PriceListData } from "@/framework/priceList";
import { loadCurrencies, convert, loadCurrencyPrefs, convertFromPrefs, type CurrenciesData, type CurrencyPrefs } from "@/framework/currencies";
import { loadJobContentByProblemId, type JobContentHierarchy } from "@/framework/jobContent";
import { loadChanges, loadBooks, downloadChanges, collectionSchemas, commitChanges, type ChangesData, type CollectionSchema, type ChangesetItem } from "@/framework/changes";
import { list as listLogs, save as saveLog, type LogEntryStored } from "@/framework/logs";
import { convertCurrency, convertToBase, formatCurrency, getCurrencySymbol } from "@/lib/currencyConvert";
import { getApi } from "@/dataAccess/getApi";
import tnfrLegacyWithHours, { docs as tnfrDocs } from "@/lib/tnfrLegacyWithHours";
import calculatePrice from "@/framework/calculatePrice";
import JsonNode from "@/components/JsonViewer.vue";
export default defineComponent({
name: "Framework",
components: { JsonNode },
setup() {
const router = useRouter();
const directoryData = ref<DirectoryData | null>(null);
const priceListData = ref<PriceListData | null>(null);
const showSearch = ref(false);
const searchQuery = ref("");
const selectedCategory = ref<string | null>(null);
const selectedTags = ref<string[]>([]);
const selectedTagId = ref("");
const showCalculatePrice = ref(false);
const selectedFormula = ref("");
const formulaDocs = ref<string | null>(null);
const showCalculator = ref(false);
const inputSectionOpen = ref(true);
const outputSectionOpen = ref(true);
const calculatorInputs = ref<Record<string, any>>({});
const calculatorDisplayInputs = ref<Record<string, number>>({});
const calculatorOutput = ref<any>(null);
const currentFormula = ref<any>(null);
const availableOffers = ref<any[]>([]);
const selectedOfferId = ref("");
const showFormulaJson = ref(false);
const formulaJsonData = ref<any>(null);
const jsonExpandAll = ref(true); // true = expanded, false = collapsed (when mode is active)
const jsonExpandMode = ref(false); // false initially, true after user clicks expand/collapse all
const jsonKey = ref(0);
const currenciesData = ref<CurrenciesData | null>(null);
const selectedCurrency = ref("");
const showJobContent = ref(false);
const availableProblems = ref<any[]>([]);
const selectedProblemId = ref("");
const jobContentData = ref<JobContentHierarchy | null>(null);
const editId = ref("");
const changesData = ref<ChangesData | null>(null);
const changesTab = ref<"history" | "schemas" | "create">("history");
const showCurrencies = ref(false);
const convertValue = ref<number>(1);
const convertFrom = ref("");
const convertTo = ref("");
const convertResult = ref<number | null>(null);
const createCollection = ref("");
const createFormData = ref<Record<string, any>>({});
const createRefInputs = ref<Record<string, string>>({});
const createdChangeRecord = ref<any>(null);
const currencyPrefsData = ref<CurrencyPrefs | null>(null);
const commitBookId = ref("");
const commitStatus = ref<{ success: boolean; message: string } | null>(null);
const availableBooks = ref<any[]>([]);
const downloadStatus = ref<{ success: boolean; message: string } | null>(null);
const isDownloading = ref(false);
const showLogs = ref(false);
const logsTab = ref<"list" | "create">("list");
const logsData = ref<LogEntryStored[]>([]);
const logsFilterType = ref("");
const logsFilterCreatedBy = ref("");
const newLogType = ref("");
const newLogCreatedBy = ref("");
const newLogCreatedByName = ref("");
const newLogData = ref("{}");
const logSaveStatus = ref<{ success: boolean; message: string } | null>(null);
function expandAllJson() {
jsonExpandAll.value = true;
jsonExpandMode.value = true;
jsonKey.value++;
}
function collapseAllJson() {
jsonExpandAll.value = false;
jsonExpandMode.value = true;
jsonKey.value++;
}
async function loadCurrencyData() {
if (!currenciesData.value) {
currenciesData.value = await loadCurrencies();
// Default to usd if available, otherwise first currency
if (!selectedCurrency.value && currenciesData.value.currencies.length > 0) {
const usd = currenciesData.value.currencies.find(c => c.refId === "usd");
selectedCurrency.value = usd?.refId || currenciesData.value.currencies[0].refId || "";
}
}
}
function formatCurrencyValue(value: number): string {
if (!currenciesData.value || !selectedCurrency.value) return "";
return formatCurrency(
value,
selectedCurrency.value,
currenciesData.value.rates,
currenciesData.value.currencies
);
}
const currentCurrencySymbol = computed(() => {
if (!currenciesData.value || !selectedCurrency.value) return "";
return getCurrencySymbol(selectedCurrency.value, currenciesData.value.currencies);
});
function convertToDisplayCurrency(baseValue: number): number {
if (!currenciesData.value || !selectedCurrency.value) return baseValue;
return convertCurrency(baseValue, selectedCurrency.value, currenciesData.value.rates);
}
function convertFromDisplayCurrency(displayValue: number): number {
if (!currenciesData.value || !selectedCurrency.value) return displayValue;
return convertToBase(displayValue, selectedCurrency.value, currenciesData.value.rates);
}
function initializeDisplayInputs() {
// Initialize display inputs for currency fields from calculatorInputs
const displayInputs: Record<string, number> = {};
for (const key of Object.keys(calculatorInputs.value)) {
const meta = calculatorInputs.value[key];
if (meta && meta.unit === 'currency' && typeof meta.value === 'number') {
displayInputs[key] = convertToDisplayCurrency(meta.value);
}
}
calculatorDisplayInputs.value = displayInputs;
}
function updateDisplayInput(key: string, event: Event) {
const target = event.target as HTMLInputElement;
const displayValue = parseFloat(target.value) || 0;
calculatorDisplayInputs.value[key] = displayValue;
// Convert back to base currency and update calculatorInputs
const baseValue = convertFromDisplayCurrency(displayValue);
if (calculatorInputs.value[key]) {
calculatorInputs.value[key].value = baseValue;
}
}
function clearAllContent() {
// Clear all content below the top row
showSearch.value = false;
directoryData.value = null;
priceListData.value = null;
showCalculatePrice.value = false;
selectedFormula.value = "";
formulaDocs.value = null;
showFormulaJson.value = false;
formulaJsonData.value = null;
showCalculator.value = false;
calculatorInputs.value = {};
calculatorDisplayInputs.value = {};
calculatorOutput.value = null;
currentFormula.value = null;
availableOffers.value = [];
selectedOfferId.value = "";
searchQuery.value = "";
selectedCategory.value = null;
selectedTags.value = [];
showJobContent.value = false;
availableProblems.value = [];
selectedProblemId.value = "";
jobContentData.value = null;
changesData.value = null;
changesTab.value = "history";
showCurrencies.value = false;
convertValue.value = 1;
convertFrom.value = "";
convertTo.value = "";
convertResult.value = null;
createCollection.value = "";
createFormData.value = {};
createRefInputs.value = {};
createdChangeRecord.value = null;
showLogs.value = false;
logsTab.value = "list";
logsData.value = [];
logsFilterType.value = "";
logsFilterCreatedBy.value = "";
newLogType.value = "";
newLogCreatedBy.value = "";
newLogCreatedByName.value = "";
newLogData.value = "{}";
logSaveStatus.value = null;
}
async function refreshDirectory() {
const query: DirectoryQuery = {
text: searchQuery.value.trim() || null,
category: selectedCategory.value,
tags: selectedTags.value,
};
directoryData.value = await loadDirectory(query);
}
async function handleDirectoryClick() {
clearAllContent();
await refreshDirectory();
showSearch.value = true;
}
async function handlePriceListClick() {
clearAllContent();
priceListData.value = await loadPriceList({ formulaFactory: tnfrLegacyWithHours });
}
async function handleJobContentClick() {
clearAllContent();
showJobContent.value = true;
// Load problems from directory
const directoryResult = await loadDirectory({ text: null, category: null, tags: [] });
availableProblems.value = directoryResult.problems;
}
async function handleGetContent() {
if (!selectedProblemId.value) return;
jobContentData.value = await loadJobContentByProblemId(selectedProblemId.value);
}
async function handleChangesClick() {
clearAllContent();
// Load changes and books in parallel
const [changesResult, booksResult] = await Promise.all([
loadChanges(),
loadBooks(),
]);
changesData.value = changesResult;
availableBooks.value = booksResult;
}
async function handleDownloadChanges() {
isDownloading.value = true;
downloadStatus.value = null;
try {
const result = await downloadChanges();
if (result.success) {
downloadStatus.value = {
success: true,
message: `Downloaded ${result.downloaded}, processed ${result.processed}`,
};
} else {
downloadStatus.value = {
success: false,
message: result.errors.join("; ") || "Download failed",
};
}
// Refresh the changes list
changesData.value = await loadChanges();
} catch (error) {
downloadStatus.value = {
success: false,
message: error instanceof Error ? error.message : String(error),
};
} finally {
isDownloading.value = false;
}
}
async function handleCurrenciesClick() {
clearAllContent();
if (!currenciesData.value) {
currenciesData.value = await loadCurrencies();
}
// Load currency prefs from userPrefs table
currencyPrefsData.value = await loadCurrencyPrefs();
showCurrencies.value = true;
}
function handleConvert() {
if (!currenciesData.value || !convertFrom.value || !convertTo.value) return;
convertResult.value = convert(
currenciesData.value.rates,
convertFrom.value,
convertTo.value,
convertValue.value
);
}
async function handleLogsClick() {
clearAllContent();
showLogs.value = true;
await handleRefreshLogs();
}
async function handleRefreshLogs() {
const options: { log_type?: string; created_by?: string } = {};
if (logsFilterType.value.trim()) {
options.log_type = logsFilterType.value.trim();
}
if (logsFilterCreatedBy.value.trim()) {
options.created_by = logsFilterCreatedBy.value.trim();
}
logsData.value = await listLogs(options);
}
async function handleSaveLog() {
logSaveStatus.value = null;
if (!newLogType.value.trim()) {
logSaveStatus.value = { success: false, message: "log_type is required" };
return;
}
if (!newLogCreatedBy.value.trim()) {
logSaveStatus.value = { success: false, message: "created_by is required" };
return;
}
if (!newLogCreatedByName.value.trim()) {
logSaveStatus.value = { success: false, message: "created_by_name is required" };
return;
}
let logData: Record<string, any>;
try {
logData = JSON.parse(newLogData.value);
} catch (e) {
logSaveStatus.value = { success: false, message: "log_data must be valid JSON" };
return;
}
try {
const id = await saveLog({
log_type: newLogType.value.trim(),
log_data: logData,
created_by: newLogCreatedBy.value.trim(),
created_by_name: newLogCreatedByName.value.trim(),
});
logSaveStatus.value = { success: true, message: `Log saved with ID: ${id}` };
// Clear form
newLogType.value = "";
newLogCreatedBy.value = "";
newLogCreatedByName.value = "";
newLogData.value = "{}";
// Refresh list
await handleRefreshLogs();
} catch (e) {
logSaveStatus.value = { success: false, message: e instanceof Error ? e.message : String(e) };
}
}
const selectedSchema = computed(() => {
if (!createCollection.value) return null;
return collectionSchemas.find(s => s.name === createCollection.value) || null;
});
function handleCollectionChange() {
// Reset form when collection changes
createFormData.value = {};
createRefInputs.value = {};
createdChangeRecord.value = null;
commitStatus.value = null;
// Initialize form with empty values based on schema
if (selectedSchema.value) {
for (const field of selectedSchema.value.fields) {
if (field.type === "ref") {
createRefInputs.value[field.name] = "";
} else if (field.type === "number") {
createFormData.value[field.name] = 0;
} else {
createFormData.value[field.name] = "";
}
}
}
}
async function generateChangeRecord() {
if (!selectedSchema.value) return;
// Reset commit status when generating new record
commitStatus.value = null;
const data: Record<string, any> = {};
const refs: any[] = [];
for (const field of selectedSchema.value.fields) {
if (field.type === "ref") {
// Handle ref fields - parse comma-separated refIds
const refInput = createRefInputs.value[field.name]?.trim();
if (refInput) {
const refIds = refInput.split(",").map(s => s.trim()).filter(Boolean);
if (refIds.length > 0) {
refs.push({
collection: field.refCollection,
refIds: refIds,
targetField: field.name,
});
}
}
} else {
// Handle regular fields
const value = createFormData.value[field.name];
if (value !== "" && value !== null && value !== undefined) {
if (field.type === "number") {
// For costsMaterial.quantity, convert from preferred currency to base
if (createCollection.value === "costsMaterial" && field.name === "quantity") {
console.log('[generateChangeRecord] Converting quantity:', value);
const baseValue = await convertFromPrefs(Number(value));
console.log('[generateChangeRecord] Converted to base:', baseValue);
if (baseValue !== null) {
data[field.name] = baseValue;
} else {
// Fallback to raw value if no currency prefs
console.log('[generateChangeRecord] No prefs, using raw value');
data[field.name] = Number(value);
}
} else {
data[field.name] = Number(value);
}
} else {
data[field.name] = value;
}
}
}
}
// Add refs array if any refs were specified
if (refs.length > 0) {
data.refs = refs;
}
createdChangeRecord.value = {
collection: createCollection.value,
operation: "create",
data: data,
};
}
function copyChangeRecord() {
if (createdChangeRecord.value) {
navigator.clipboard.writeText(JSON.stringify(createdChangeRecord.value, null, 2));
}
}
async function commitChangeRecord() {
if (!createdChangeRecord.value) {
commitStatus.value = { success: false, message: "No change record to commit" };
return;
}
if (!commitBookId.value) {
commitStatus.value = { success: false, message: "Please select a book" };
return;
}
commitStatus.value = null;
const result = await commitChanges(
[createdChangeRecord.value as ChangesetItem],
{ bookId: commitBookId.value }
);
if (result.success) {
commitStatus.value = { success: true, message: `Committed! Record ID: ${result.recordId}` };
// Refresh changes list
changesData.value = await loadChanges();
} else {
commitStatus.value = { success: false, message: result.error || "Commit failed" };
}
}
async function handleCalculatePriceClick() {
clearAllContent();
showCalculatePrice.value = true;
// Load offers and currencies for the dropdowns
const [priceListResult] = await Promise.all([
loadPriceList({ formulaFactory: tnfrLegacyWithHours }),
loadCurrencyData(),
]);
availableOffers.value = priceListResult.offers;
// Auto-select the first formula
selectedFormula.value = "tnfrLegacyWithHours";
handleFormulaChange();
}
function handleFormulaChange() {
// Reset calculator state when formula changes
showCalculator.value = false;
showFormulaJson.value = false;
formulaDocs.value = null;
formulaJsonData.value = null;
calculatorOutput.value = null;
selectedOfferId.value = "";
if (selectedFormula.value === "tnfrLegacyWithHours") {
currentFormula.value = tnfrLegacyWithHours();
calculatorInputs.value = { ...currentFormula.value.vars };
initializeDisplayInputs();
} else {
currentFormula.value = null;
calculatorInputs.value = {};
calculatorDisplayInputs.value = {};
}
}
function handleOfferSelect() {
if (!selectedOfferId.value || !currentFormula.value) return;
const offer = availableOffers.value.find(o => o.id === selectedOfferId.value);
if (!offer) return;
// Calculate materialCostBase from costsMaterial
const materialCostBase = (offer.costsMaterial || []).reduce(
(total: number, item: any) => total + (Number(item?.quantity) || 0),
0
);
// Calculate timeCostBase from costsTime
const timeCostBase = (offer.costsTime || []).reduce(
(total: number, item: any) => total + (Number(item?.hours) || 0),
0
);
// Update calculator inputs (set .value for VarMeta objects)
if (calculatorInputs.value.materialCostBase) {
calculatorInputs.value.materialCostBase.value = materialCostBase;
// Update display input for currency field
calculatorDisplayInputs.value.materialCostBase = convertToDisplayCurrency(materialCostBase);
}
if (calculatorInputs.value.timeCostBase) {
calculatorInputs.value.timeCostBase.value = timeCostBase;
}
// Clear previous output
calculatorOutput.value = null;
}
function handleShowDocs() {
showCalculator.value = false;
showFormulaJson.value = false;
if (selectedFormula.value === "tnfrLegacyWithHours") {
formulaDocs.value = tnfrDocs;
}
}
function handleShowFormulaJson() {
showCalculator.value = false;
formulaDocs.value = null;
showFormulaJson.value = true;
if (currentFormula.value) {
formulaJsonData.value = currentFormula.value;
}
}
function handleShowCalculator() {
formulaDocs.value = null;
showFormulaJson.value = false;
showCalculator.value = true;
calculatorOutput.value = null;
}
async function runCalculation() {
if (!currentFormula.value) return;
// Create a fresh formula instance and apply input values
const formula = selectedFormula.value === "tnfrLegacyWithHours"
? tnfrLegacyWithHours()
: null;
if (!formula) return;
// Apply user inputs to formula vars (copy VarMeta objects)
const vars = formula.vars as Record<string, any>;
for (const key of Object.keys(calculatorInputs.value)) {
if (key in vars) {
const input = calculatorInputs.value[key];
if (input && typeof input === 'object' && 'value' in input) {
vars[key].value = input.value;
} else {
vars[key] = input;
}
}
}
// Run calculation (now async, includes currency conversion from prefs)
const result = await calculatePrice([], [], formula);
// Set the output directly - calculatePrice now includes converted values
calculatorOutput.value = result;
// Force re-render of json-node
jsonKey.value++;
}
async function handleSearch() {
// Text search clears category filter but keeps tags
selectedCategory.value = null;
await refreshDirectory();
}
async function handleClearSearch() {
searchQuery.value = "";
await refreshDirectory();
}
function getCategoryProblemCount(categoryId: string): number {
if (!directoryData.value) return 0;
const category = directoryData.value.categoryView.find(c => c.id === categoryId);
return category?.problems.length || 0;
}
function getTotalCategoryProblems(): number {
if (!directoryData.value) return 0;
return directoryData.value.categoryView.reduce((sum, cat) => sum + cat.problems.length, 0);
}
async function handleCategoryClick(category: any) {
// Toggle category selection; clears text search but keeps tags
searchQuery.value = "";
if (selectedCategory.value === category.id) {
selectedCategory.value = null;
} else {
selectedCategory.value = category.id;
}
await refreshDirectory();
}
async function handleTagSelect() {
if (selectedTagId.value && !selectedTags.value.includes(selectedTagId.value)) {
selectedTags.value = [...selectedTags.value, selectedTagId.value];
await refreshDirectory();
}
selectedTagId.value = "";
}
function getTagName(tagId: string): string {
if (!directoryData.value) return tagId;
const tag = directoryData.value.tags.find(t => t.id === tagId);
return tag?.name || tagId;
}
async function removeTag(tagId: string) {
selectedTags.value = selectedTags.value.filter(t => t !== tagId);
await refreshDirectory();
}
async function handleClear() {
// If directory is showing and there's input to clear, clear filters and refresh
if (directoryData.value && (searchQuery.value || selectedCategory.value || selectedTags.value.length)) {
searchQuery.value = "";
selectedCategory.value = null;
selectedTags.value = [];
await refreshDirectory();
} else {
// Otherwise clear all content
clearAllContent();
}
}
async function handleRelogin() {
const { pb } = await getApi();
const email = pb.authStore.record?.email;
if (email) {
localStorage.setItem("relogin_email", email);
localStorage.setItem("relogin_returnTo", "/framework");
}
router.push("/auth/logout");
}
function handleEditNavigate() {
if (editId.value.trim()) {
window.open(`/editor/${editId.value.trim()}`, '_blank');
}
}
function onCopyToEdit(text: string) {
editId.value = text;
}
// Load currencies on mount
onMounted(() => {
loadCurrencyData();
});
return {
directoryData,
priceListData,
handleDirectoryClick,
handleCalculatePriceClick,
handlePriceListClick,
handleRelogin,
showSearch,
searchQuery,
handleSearch,
handleClearSearch,
getCategoryProblemCount,
getTotalCategoryProblems,
handleCategoryClick,
handleTagSelect,
getTagName,
removeTag,
handleClear,
selectedCategory,
selectedTags,
selectedTagId,
showCalculatePrice,
selectedFormula,
formulaDocs,
handleShowDocs,
handleShowFormulaJson,
showFormulaJson,
formulaJsonData,
jsonExpandAll,
jsonExpandMode,
jsonKey,
expandAllJson,
collapseAllJson,
handleFormulaChange,
handleShowCalculator,
showCalculator,
inputSectionOpen,
outputSectionOpen,
calculatorInputs,
calculatorDisplayInputs,
calculatorOutput,
runCalculation,
currentCurrencySymbol,
updateDisplayInput,
availableOffers,
selectedOfferId,
handleOfferSelect,
currenciesData,
selectedCurrency,
formatCurrencyValue,
showJobContent,
availableProblems,
selectedProblemId,
jobContentData,
handleJobContentClick,
handleGetContent,
editId,
handleEditNavigate,
onCopyToEdit,
changesData,
handleChangesClick,
handleDownloadChanges,
downloadStatus,
isDownloading,
changesTab,
collectionSchemas,
createCollection,
createFormData,
createRefInputs,
createdChangeRecord,
selectedSchema,
handleCollectionChange,
generateChangeRecord,
copyChangeRecord,
commitChangeRecord,
commitBookId,
commitStatus,
availableBooks,
showCurrencies,
handleCurrenciesClick,
convertValue,
convertFrom,
convertTo,
convertResult,
handleConvert,
currencyPrefsData,
showLogs,
logsTab,
logsData,
logsFilterType,
logsFilterCreatedBy,
newLogType,
newLogCreatedBy,
newLogCreatedByName,
newLogData,
logSaveStatus,
handleLogsClick,
handleRefreshLogs,
handleSaveLog,
};
},
});
</script>
<style scoped>
.framework-view {
padding: 16px;
padding-bottom: 100px;
font-family: system-ui, sans-serif;
min-height: 100vh;
overflow-y: auto;
}
.top-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.top-row-left,
.top-row-right {
display: flex;
gap: 8px;
}
button {
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.top-row button {
margin-right: 0;
}
.second-row {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.second-row-right {
display: flex;
gap: 8px;
align-items: center;
}
.edit-input {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
width: 200px;
}
.currency-select {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
background: white;
min-width: 100px;
}
.calculate-price-row {
margin-top: 12px;
display: flex;
gap: 8px;
align-items: center;
}
.formula-select {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
background: white;
min-width: 200px;
}
.job-content-section {
margin-top: 12px;
}
.changes-section {
margin-top: 12px;
}
.changes-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.changes-controls button {
padding: 6px 12px;
font-size: 13px;
background: #f0f0f0;
border: 1px solid #ccc;
}
.changes-controls button.active {
background: #0066cc;
color: white;
border-color: #0055aa;
}
.changes-controls .download-btn {
margin-left: auto;
background: #28a745;
color: white;
border-color: #218838;
}
.changes-controls .download-btn:hover:not(:disabled) {
background: #218838;
}
.changes-controls .download-btn:disabled {
background: #6c757d;
cursor: wait;
}
.download-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.download-status.success {
background: #d4edda;
color: #155724;
}
.download-status.error {
background: #f8d7da;
color: #721c24;
}
.schemas-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.schema-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
overflow: hidden;
}
.schema-header {
padding: 10px 12px;
background: #e9ecef;
display: flex;
align-items: baseline;
gap: 12px;
}
.schema-name {
font-weight: 600;
font-family: monospace;
font-size: 14px;
color: #0066cc;
}
.schema-desc {
font-size: 12px;
color: #666;
}
.schema-fields {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.schema-fields th {
text-align: left;
padding: 8px 12px;
background: #f0f0f0;
font-weight: 600;
color: #666;
border-bottom: 1px solid #ddd;
}
.schema-fields td {
padding: 6px 12px;
border-bottom: 1px solid #eee;
}
.schema-fields tr:last-child td {
border-bottom: none;
}
.schema-fields tr.required {
background: #fffdf0;
}
.field-name {
font-family: monospace;
font-weight: 500;
}
.field-type {
font-family: monospace;
color: #666;
}
.ref-target {
color: #0066cc;
font-size: 11px;
}
.field-required {
color: #cc6600;
font-weight: 500;
}
.field-desc {
color: #666;
}
/* Currencies Section Styles */
.currencies-section {
margin-top: 12px;
}
.currencies-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f0f0f0;
border-radius: 4px;
margin-bottom: 12px;
}
.toolbar-label {
font-family: monospace;
font-size: 13px;
font-weight: 600;
color: #0066cc;
}
.toolbar-arrow {
color: #666;
font-size: 14px;
}
.convert-input {
width: 100px;
padding: 6px 8px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
}
.convert-select {
padding: 6px 8px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
}
.convert-result {
font-family: monospace;
font-size: 14px;
font-weight: 600;
color: #28a745;
margin-left: 8px;
}
.currencies-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.currencies-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.currencies-card h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.currencies-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.currencies-table th {
text-align: left;
padding: 6px 8px;
background: #e9ecef;
font-weight: 600;
color: #666;
}
.currencies-table td {
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.currencies-table tr:last-child td {
border-bottom: none;
}
.currencies-table .mono {
font-family: monospace;
}
.prefs-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.prefs-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid #eee;
}
.prefs-row:last-child {
border-bottom: none;
}
.prefs-label {
font-size: 12px;
color: #666;
}
.prefs-value {
font-size: 13px;
font-weight: 500;
}
.no-prefs {
color: #999;
font-style: italic;
font-size: 13px;
}
/* Create Form Styles */
.create-form {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 16px;
}
.schema-info {
font-size: 13px;
color: #666;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e9ecef;
}
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.form-row label {
min-width: 140px;
font-size: 13px;
font-family: monospace;
font-weight: 500;
}
.form-row label.required::after {
content: " *";
color: #cc6600;
}
.field-hint {
display: block;
font-size: 10px;
color: #888;
font-weight: normal;
}
.form-input {
flex: 1;
padding: 8px 12px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
max-width: 400px;
}
.form-input:focus {
outline: none;
border-color: #0066cc;
}
.input-with-hint {
display: flex;
align-items: center;
gap: 8px;
}
.currency-hint {
font-size: 11px;
color: #666;
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
}
.collection-select {
padding: 8px 12px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
min-width: 200px;
}
.form-actions {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e9ecef;
}
.form-actions button {
padding: 10px 20px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.form-actions button:hover {
background: #0055aa;
}
.created-record {
margin-top: 16px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
font-size: 13px;
font-weight: 600;
}
.record-header button {
padding: 4px 12px;
font-size: 12px;
background: #28a745;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.record-header button:hover {
background: #218838;
}
.created-record pre {
margin: 0;
padding: 12px;
font-size: 12px;
overflow-x: auto;
background: #fafafa;
}
.commit-section {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid #ddd;
background: #f8f9fa;
}
.book-select {
padding: 8px 12px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
min-width: 200px;
}
.commit-button {
padding: 8px 16px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.commit-button:hover:not(:disabled) {
background: #0055aa;
}
.commit-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.commit-status {
padding: 8px 12px;
font-size: 13px;
border-top: 1px solid #ddd;
}
.commit-status.success {
background: #d4edda;
color: #155724;
}
.commit-status.error {
background: #f8d7da;
color: #721c24;
}
.job-content-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.problem-select {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
background: white;
min-width: 300px;
}
.formula-docs {
margin-top: 12px;
padding: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
}
.formula-docs pre {
margin: 0;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
}
.formula-json {
margin-top: 12px;
}
.formula-json pre {
margin: 0;
padding: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
max-height: 500px;
overflow-y: auto;
}
.calculator-view {
margin-top: 12px;
}
.collapsible-section {
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 12px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #f5f5f5;
cursor: pointer;
font-weight: 600;
user-select: none;
}
.section-header:hover {
background: #eee;
}
.collapse-icon {
width: 16px;
text-align: center;
font-weight: bold;
}
.section-content {
padding: 12px;
}
.input-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.input-row label {
min-width: 220px;
font-size: 13px;
font-family: monospace;
}
.unit-label {
color: #888;
font-size: 11px;
}
.converted-value {
color: #0066cc;
font-size: 13px;
font-family: monospace;
margin-left: 8px;
}
.input-row input {
padding: 6px 10px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 3px;
width: 150px;
}
.currency-symbol {
font-size: 13px;
font-family: monospace;
color: #666;
margin-right: 4px;
}
.input-row .array-value {
font-size: 13px;
color: #666;
font-style: italic;
}
.run-button {
margin-top: 12px;
padding: 10px 20px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.run-button:hover {
background: #0055aa;
}
.output-row {
display: flex;
gap: 12px;
margin-bottom: 6px;
font-size: 13px;
}
.output-label {
min-width: 180px;
font-family: monospace;
color: #666;
}
.output-value {
font-family: monospace;
font-weight: 600;
}
.derived-vars {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eee;
}
.no-output {
color: #999;
font-style: italic;
}
.search-container {
margin-top: 12px;
display: flex;
gap: 8px;
}
.search-container input {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
flex: 1;
max-width: 300px;
}
.filter-section {
margin-top: 12px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 6px;
}
.category-buttons,
.tag-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category-btn,
.tag-btn {
padding: 4px 10px;
font-size: 12px;
background: #f0f0f0;
border: 1px solid #ccc;
}
.category-btn.selected,
.tag-btn.selected {
background: #0066cc;
color: white;
border-color: #0055aa;
}
.tag-select {
padding: 6px 10px;
font-size: 12px;
border: 1px solid #ccc;
background: white;
min-width: 180px;
}
.selected-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 12px;
background: #e0e7ff;
border: 1px solid #a5b4fc;
border-radius: 12px;
}
.tag-remove {
padding: 0;
margin: 0;
border: none;
background: none;
font-size: 14px;
line-height: 1;
cursor: pointer;
color: #6366f1;
}
.tag-remove:hover {
color: #4338ca;
}
.results-info {
margin-top: 12px;
font-size: 13px;
color: #666;
}
.json-block {
margin-top: 16px;
}
.json-block pre {
margin-top: 0;
}
.json-controls {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.json-controls button {
padding: 4px 10px;
font-size: 12px;
background: #f0f0f0;
border: 1px solid #ccc;
cursor: pointer;
}
.json-controls button:hover {
background: #e0e0e0;
}
pre {
margin-top: 16px;
padding: 12px;
background: #f5f5f5;
border: 1px solid #ddd;
overflow-x: auto;
}
code {
font-family: monospace;
font-size: 12px;
white-space: pre;
}
/* Logs Section Styles */
.logs-section {
margin-top: 12px;
}
.logs-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.logs-controls button {
padding: 6px 12px;
font-size: 13px;
background: #f0f0f0;
border: 1px solid #ccc;
}
.logs-controls button.active {
background: #0066cc;
color: white;
border-color: #0055aa;
}
.logs-controls .refresh-btn {
margin-left: auto;
background: #28a745;
color: white;
border-color: #218838;
}
.logs-controls .refresh-btn:hover {
background: #218838;
}
.logs-filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.filter-input {
padding: 6px 10px;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
width: 180px;
}
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
overflow: hidden;
}
.logs-table th {
text-align: left;
padding: 10px 12px;
background: #e9ecef;
font-weight: 600;
color: #666;
border-bottom: 1px solid #ddd;
}
.logs-table td {
padding: 8px 12px;
border-bottom: 1px solid #eee;
vertical-align: top;
}
.logs-table tr:last-child td {
border-bottom: none;
}
.logs-table .mono {
font-family: monospace;
}
.created-by-id {
font-size: 10px;
color: #888;
}
.log-data-cell details {
cursor: pointer;
}
.log-data-cell summary {
font-size: 11px;
color: #0066cc;
}
.log-data-cell pre {
margin: 8px 0 0 0;
padding: 8px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 11px;
max-width: 400px;
overflow-x: auto;
}
.form-textarea {
flex: 1;
padding: 8px 12px;
font-size: 13px;
font-family: monospace;
border: 1px solid #ccc;
border-radius: 4px;
max-width: 400px;
min-height: 100px;
resize: vertical;
}
</style>